Scroll Modal
Original · freemodal with long scrollable body
byWeb InnoventixReact + Tailwind
modalscrollmodals
Copy prompt gives Claude Code, Cursor or v0 a ready-to-paste prompt that recreates this exact component.
npx shadcn@latest add https://webinnoventix.com/r/modal-scroll.jsonmodal-scroll.tsx
"use client";
import {
useCallback,
useEffect,
useId,
useRef,
useState,
type KeyboardEvent as ReactKeyboardEvent,
type UIEvent as ReactUIEvent,
} from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type Clause = {
id: string;
heading: string;
paragraphs: string[];
};
const CLAUSES: Clause[] = [
{
id: "acceptance",
heading: "1. Acceptance of these Terms",
paragraphs: [
"This Master Subscription Agreement (the \"Agreement\") governs your access to and use of Meridian Analytics, a hosted platform for product and revenue analytics operated by Northgate Data Systems Ltd. By clicking \"I Agree,\" creating a workspace, or accessing any part of the Service, you accept every term below on behalf of yourself and the organization you represent.",
"If you are entering into this Agreement for a company, you confirm that you have authority to bind that company. Where you lack that authority, you must not accept these terms or use the Service.",
],
},
{
id: "service",
heading: "2. Description of the Service",
paragraphs: [
"Meridian Analytics ingests event and transaction data you send us, stores it in an isolated tenant, and returns dashboards, cohort reports, and export files. The Service is delivered as software-as-a-service; you receive a right to use it, not a copy of it.",
"We ship changes continuously. Features may be added, refined, or retired, but we will not remove a capability that is material to a paid plan during a billing term without at least thirty days' written notice sent to your account owner.",
],
},
{
id: "accounts",
heading: "3. Accounts and Security",
paragraphs: [
"You are responsible for every action taken under your workspace, including by teammates you invite and by API keys you issue. Keep credentials confidential, rotate keys that may be exposed, and enable two-factor authentication on all administrator seats.",
"Notify our security team within seventy-two hours of discovering any unauthorized access. We may suspend a workspace we reasonably believe to be compromised, and we will restore it once the risk is contained.",
],
},
{
id: "billing",
heading: "4. Subscription, Billing, and Renewal",
paragraphs: [
"Paid plans are billed in advance on a monthly or annual cadence based on the tier you select. Usage that exceeds your plan's included event volume is metered and invoiced in arrears at the overage rate published on your billing page.",
"Subscriptions renew automatically for successive terms unless you cancel before the current term ends. Fees already paid are non-refundable except where required by law, and downgrades take effect at the start of the next billing term.",
],
},
{
id: "acceptable-use",
heading: "5. Acceptable Use",
paragraphs: [
"You may not use the Service to store unlawful content, to reverse-engineer the platform, to probe or bypass our access controls, or to send data you are not permitted to process. You may not resell raw access to the Service without a written reseller addendum.",
"Automated traffic must respect the documented rate limits. We may throttle or block requests that threaten the stability of shared infrastructure, and we will tell you why when we do.",
],
},
{
id: "data",
heading: "6. Your Data and Privacy",
paragraphs: [
"You own the data you submit. You grant us a limited license to host, process, and transmit that data solely to operate and improve the Service for you. We do not sell your data and we do not use the contents of your workspace to train models for other customers.",
"Our Data Processing Addendum sets out how we handle personal data, including sub-processors, transfer mechanisms, and breach notification. Where that addendum conflicts with this Agreement on privacy matters, the addendum controls.",
],
},
{
id: "availability",
heading: "7. Availability and Support",
paragraphs: [
"We target 99.9% monthly uptime for the production API, measured excluding scheduled maintenance announced at least forty-eight hours in advance. If we miss the target, the Service Level Agreement describes the credits you may claim.",
"Standard support is available by email during business hours in Central European Time. Priority and dedicated support tiers, including named contacts and faster response windows, are described on your plan page.",
],
},
{
id: "ip",
heading: "8. Intellectual Property",
paragraphs: [
"The Service, its underlying software, and all improvements remain the exclusive property of Northgate Data Systems Ltd. and its licensors. Nothing in this Agreement transfers ownership of that intellectual property to you.",
"If you send us feedback or suggestions, you allow us to use them without restriction or obligation. This does not give us any right to your confidential business information beyond what is needed to act on the feedback.",
],
},
{
id: "warranty",
heading: "9. Warranties and Disclaimers",
paragraphs: [
"We warrant that the Service will perform materially in line with its published documentation. Except for that warranty, the Service is provided \"as is,\" and we disclaim all other warranties to the fullest extent permitted by law, including fitness for a particular purpose.",
"We do not warrant that the Service will be uninterrupted or error-free, or that every defect will be corrected. Beta features are offered without any warranty and may change or disappear at any time.",
],
},
{
id: "liability",
heading: "10. Limitation of Liability",
paragraphs: [
"To the maximum extent permitted by law, neither party will be liable for indirect, incidental, or consequential damages, or for lost profits or data, arising out of this Agreement. This limit applies even if a remedy fails of its essential purpose.",
"Each party's total aggregate liability is capped at the fees you paid for the Service in the twelve months before the event giving rise to the claim. Nothing here limits liability that cannot be limited under applicable law.",
],
},
{
id: "termination",
heading: "11. Term and Termination",
paragraphs: [
"Either party may terminate this Agreement for material breach that remains uncured thirty days after written notice. You may cancel a subscription at any time from your billing settings; cancellation stops future renewals but does not entitle you to a refund of the current term.",
"On termination we will make your data available for export for thirty days, after which we will delete it from active systems within the timeline in our retention policy. Provisions that by their nature should survive termination will survive.",
],
},
{
id: "law",
heading: "12. Governing Law and Changes",
paragraphs: [
"This Agreement is governed by the laws of England and Wales, without regard to conflict-of-laws rules. The courts of London have exclusive jurisdiction over disputes that are not resolved through the informal process described in the Support Policy.",
"We may update these terms from time to time. When a change is material, we will notify your account owner at least thirty days before it takes effect, and your continued use after that date constitutes acceptance of the revised terms.",
],
},
];
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
type Decision = "accepted" | "declined" | null;
export default function ModalScroll() {
const prefersReduced = useReducedMotion();
const [open, setOpen] = useState(false);
const [progress, setProgress] = useState(0);
const [reachedEnd, setReachedEnd] = useState(false);
const [agreed, setAgreed] = useState(false);
const [decision, setDecision] = useState<Decision>(null);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const bodyRef = useRef<HTMLDivElement | null>(null);
const closeRef = useRef<HTMLButtonElement | null>(null);
const titleId = useId();
const descId = useId();
const progressId = useId();
const measure = useCallback(() => {
const el = bodyRef.current;
if (!el) return;
const max = el.scrollHeight - el.clientHeight;
const pct = max <= 0 ? 100 : Math.min(100, Math.round((el.scrollTop / max) * 100));
setProgress(pct);
if (pct >= 98) setReachedEnd(true);
}, []);
const handleScroll = useCallback(
(_event: ReactUIEvent<HTMLDivElement>) => {
measure();
},
[measure],
);
const openModal = useCallback(() => {
setProgress(0);
setReachedEnd(false);
setAgreed(false);
setOpen(true);
}, []);
const closeModal = useCallback(() => {
setOpen(false);
}, []);
const accept = useCallback(() => {
setDecision("accepted");
setOpen(false);
}, []);
const decline = useCallback(() => {
setDecision("declined");
setOpen(false);
}, []);
// Lock body scroll, focus the panel, restore focus on close.
useEffect(() => {
if (!open) return;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const frame = window.requestAnimationFrame(() => {
closeRef.current?.focus();
measure();
});
return () => {
window.cancelAnimationFrame(frame);
document.body.style.overflow = previousOverflow;
triggerRef.current?.focus();
};
}, [open, measure]);
// Escape to close + focus trap.
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
closeModal();
return;
}
if (event.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const nodes = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
).filter((node) => node.offsetParent !== null || node === document.activeElement);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !panel.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [open, closeModal]);
const onCheckboxKey = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (reachedEnd) setAgreed((value) => !value);
}
};
const backdropTransition = { duration: prefersReduced ? 0 : 0.2, ease: "easeOut" as const };
const panelHidden = prefersReduced
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: 14 };
const panelVisible = prefersReduced
? { opacity: 1 }
: { opacity: 1, scale: 1, y: 0 };
const panelExit = prefersReduced
? { opacity: 0 }
: { opacity: 0, scale: 0.97, y: 10 };
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-6 py-20 text-slate-900 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes msx-nudge {
0%, 100% { transform: translateY(0); opacity: 0.55; }
50% { transform: translateY(4px); opacity: 1; }
}
@keyframes msx-sheen {
0% { background-position: -140% 0; }
100% { background-position: 240% 0; }
}
.msx-nudge { animation: msx-nudge 1.5s ease-in-out infinite; }
.msx-sheen {
background-image: linear-gradient(110deg, transparent 30%, rgba(255,255,255,0.55) 50%, transparent 70%);
background-size: 200% 100%;
animation: msx-sheen 2.4s linear infinite;
}
@media (prefers-reduced-motion: reduce) {
.msx-nudge, .msx-sheen { animation: none !important; }
}
`}</style>
{/* Ambient backdrop texture */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 -z-10 opacity-70 [background-image:radial-gradient(circle_at_15%_15%,theme(colors.indigo.200/0.45),transparent_45%),radial-gradient(circle_at_85%_10%,theme(colors.violet.200/0.4),transparent_40%)] dark:opacity-100 dark:[background-image:radial-gradient(circle_at_15%_15%,theme(colors.indigo.500/0.16),transparent_45%),radial-gradient(circle_at_85%_10%,theme(colors.violet.500/0.14),transparent_40%)]"
/>
<div className="mx-auto max-w-2xl text-center">
<span className="inline-flex items-center gap-2 rounded-full border border-indigo-200 bg-white/70 px-3 py-1 text-xs font-medium uppercase tracking-[0.14em] text-indigo-700 backdrop-blur dark:border-indigo-400/30 dark:bg-white/5 dark:text-indigo-300">
<span className="h-1.5 w-1.5 rounded-full bg-indigo-500" />
Meridian Analytics
</span>
<h2 className="mt-6 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
Review the Master Subscription Agreement
</h2>
<p className="mx-auto mt-4 max-w-xl text-pretty text-base leading-relaxed text-slate-600 dark:text-slate-400">
Before your workspace goes live, read the full agreement below. Scroll
to the end to unlock acceptance — the checkbox stays locked until you
have seen every clause.
</p>
<div className="mt-8 flex flex-col items-center gap-3">
<button
ref={triggerRef}
type="button"
onClick={openModal}
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-5 py-3 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-slate-50 active:scale-[0.98] dark:focus-visible:ring-offset-slate-950"
>
<DocumentIcon className="h-4 w-4" />
Open the agreement
</button>
<p aria-live="polite" className="min-h-5 text-sm font-medium">
{decision === "accepted" && (
<span className="inline-flex items-center gap-1.5 text-emerald-600 dark:text-emerald-400">
<CheckIcon className="h-4 w-4" />
You accepted the agreement — your workspace is being provisioned.
</span>
)}
{decision === "declined" && (
<span className="inline-flex items-center gap-1.5 text-rose-600 dark:text-rose-400">
<XIcon className="h-4 w-4" />
You declined. Reach out to sales if terms need adjusting.
</span>
)}
</p>
</div>
</div>
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-end justify-center p-0 sm:items-center sm:p-6">
<motion.div
key="backdrop"
aria-hidden="true"
onClick={closeModal}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={backdropTransition}
className="absolute inset-0 bg-slate-950/55 backdrop-blur-sm"
/>
<motion.div
key="panel"
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
initial={panelHidden}
animate={panelVisible}
exit={panelExit}
transition={{
duration: prefersReduced ? 0 : 0.26,
ease: [0.16, 1, 0.3, 1],
}}
className="relative flex max-h-[92vh] w-full max-w-lg flex-col overflow-hidden rounded-t-3xl border border-slate-200 bg-white shadow-2xl shadow-slate-950/20 sm:max-h-[86vh] sm:rounded-3xl dark:border-slate-800 dark:bg-slate-900"
>
{/* Header */}
<header className="relative shrink-0 border-b border-slate-200 px-6 pb-4 pt-6 dark:border-slate-800">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-medium uppercase tracking-[0.14em] text-indigo-600 dark:text-indigo-400">
Legal · v4.2 · Effective 16 July 2026
</p>
<h3
id={titleId}
className="mt-1 text-lg font-semibold tracking-tight text-slate-900 dark:text-slate-50"
>
Master Subscription Agreement
</h3>
</div>
<button
ref={closeRef}
type="button"
onClick={closeModal}
aria-label="Close the agreement"
className="-mr-1.5 -mt-1.5 inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-slate-500 transition hover:bg-slate-100 hover:text-slate-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
>
<XIcon className="h-5 w-5" />
</button>
</div>
{/* Scroll progress */}
<div className="mt-4">
<div className="flex items-center justify-between text-xs font-medium text-slate-500 dark:text-slate-400">
<span id={progressId}>Reading progress</span>
<span
className={
reachedEnd
? "text-emerald-600 dark:text-emerald-400"
: "tabular-nums"
}
>
{reachedEnd ? "Complete" : `${progress}%`}
</span>
</div>
<div
role="progressbar"
aria-labelledby={progressId}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={progress}
className="mt-1.5 h-1.5 w-full overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800"
>
<div
className={`h-full rounded-full transition-[width] duration-150 ease-out ${
reachedEnd ? "bg-emerald-500" : "bg-indigo-500"
}`}
style={{ width: `${progress}%` }}
/>
</div>
</div>
</header>
{/* Scrollable body */}
<div
ref={bodyRef}
onScroll={handleScroll}
tabIndex={0}
aria-label="Agreement text, scrollable"
className="relative min-h-0 flex-1 overflow-y-auto overscroll-contain px-6 py-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-indigo-500 [scrollbar-width:thin]"
>
<p id={descId} className="text-sm leading-relaxed text-slate-600 dark:text-slate-400">
This agreement is between you and Northgate Data Systems Ltd.
Please read all twelve clauses carefully; the acceptance
controls unlock once you reach the end.
</p>
<div className="mt-6 space-y-7">
{CLAUSES.map((clause) => (
<article key={clause.id} className="scroll-mt-4">
<h4 className="text-sm font-semibold tracking-tight text-slate-900 dark:text-slate-100">
{clause.heading}
</h4>
<div className="mt-2 space-y-3">
{clause.paragraphs.map((paragraph, index) => (
<p
key={index}
className="text-sm leading-relaxed text-slate-600 dark:text-slate-400"
>
{paragraph}
</p>
))}
</div>
</article>
))}
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3 text-xs leading-relaxed text-slate-500 dark:border-slate-800 dark:bg-slate-800/40 dark:text-slate-400">
You have reached the end of the agreement. Questions about any
clause can go to legal@meridian-analytics.example before you
accept.
</div>
</div>
</div>
{/* Scroll hint */}
<AnimatePresence>
{!reachedEnd && (
<motion.div
key="hint"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: prefersReduced ? 0 : 0.2 }}
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-[6.5rem] flex justify-center"
>
<span className="inline-flex items-center gap-1.5 rounded-full bg-slate-900/85 px-3 py-1 text-xs font-medium text-white shadow-lg backdrop-blur dark:bg-slate-100/90 dark:text-slate-900">
<ChevronDownIcon className="msx-nudge h-3.5 w-3.5" />
Scroll to continue
</span>
</motion.div>
)}
</AnimatePresence>
{/* Footer */}
<footer className="shrink-0 border-t border-slate-200 bg-white px-6 py-4 dark:border-slate-800 dark:bg-slate-900">
<label
className={`flex cursor-pointer items-start gap-3 ${
reachedEnd ? "" : "cursor-not-allowed opacity-55"
}`}
>
<button
type="button"
role="checkbox"
aria-checked={agreed}
aria-disabled={!reachedEnd}
disabled={!reachedEnd}
onClick={() => reachedEnd && setAgreed((value) => !value)}
onKeyDown={onCheckboxKey}
className={`mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed dark:focus-visible:ring-offset-slate-900 ${
agreed
? "border-indigo-600 bg-indigo-600 text-white"
: "border-slate-300 bg-white dark:border-slate-600 dark:bg-slate-800"
}`}
>
{agreed && <CheckIcon className="h-3.5 w-3.5" />}
</button>
<span className="text-sm leading-relaxed text-slate-700 dark:text-slate-300">
I have read and agree to the Master Subscription Agreement on
behalf of my organization.
{!reachedEnd && (
<span className="mt-0.5 block text-xs text-slate-400 dark:text-slate-500">
Scroll to the end to enable this option.
</span>
)}
</span>
</label>
<div className="mt-4 flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
<button
type="button"
onClick={decline}
className="inline-flex items-center justify-center rounded-xl border border-slate-300 px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-slate-400 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-700 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Decline
</button>
<button
type="button"
onClick={accept}
disabled={!agreed}
className="relative inline-flex items-center justify-center gap-2 overflow-hidden rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-600/25 transition hover:bg-indigo-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white disabled:cursor-not-allowed disabled:bg-slate-300 disabled:text-slate-500 disabled:shadow-none dark:focus-visible:ring-offset-slate-900 dark:disabled:bg-slate-700 dark:disabled:text-slate-400"
>
{agreed && (
<span
aria-hidden="true"
className="msx-sheen pointer-events-none absolute inset-0"
/>
)}
<span className="relative">Accept & continue</span>
</button>
</div>
</footer>
</motion.div>
</div>
)}
</AnimatePresence>
</section>
);
}
type IconProps = { className?: string };
function DocumentIcon({ className }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={1.8}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z" />
<path d="M14 3v5h5" />
<path d="M9 13h6" />
<path d="M9 17h4" />
</svg>
);
}
function ChevronDownIcon({ className }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}
function CheckIcon({ className }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.4}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="m5 12 4.5 4.5L19 7" />
</svg>
);
}
function XIcon({ className }: IconProps) {
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
className={className}
>
<path d="M18 6 6 18" />
<path d="m6 6 12 12" />
</svg>
);
}Dependencies
motion
Licence
Built by Web Innoventix. Free for personal and commercial use, no attribution required.
Built by Web Innoventix
Need a custom interface, a full website, or SEO that gets you cited by AI? We design, build and rank it end to end.
Get a free quoteSimilar components
Browse all →
Basic Modal
Originalbasic centred modal with overlay

Center Modal
Originalcentred modal with icon and actions

Form Modal
Originalmodal containing a form

Confirm Modal
Originalconfirm/destructive dialog

Alert Modal
Originalalert dialog with single action

Drawer Bottom Modal
Originalbottom sheet drawer

Side Drawer Modal
Originalright side drawer modal

Fullscreen Modal
Originalfullscreen modal

Image Lightbox Modal
Originalimage lightbox modal with nav

Video Modal
Originalvideo player modal

Steps Modal
Originalmulti-step modal flow

Command Palette Modal
Originalcommand palette modal with search

