Basic Modal
Original · freebasic centred modal with overlay
byWeb InnoventixReact + Tailwind
modalbasicmodals
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-basic.jsonmodal-basic.tsx
"use client";
import { useCallback, useEffect, useId, useRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
export default function ModalBasic() {
const [open, setOpen] = useState(false);
const [notify, setNotify] = useState(true);
const [status, setStatus] = useState<string | null>(null);
const reduceMotion = useReducedMotion();
const triggerRef = useRef<HTMLButtonElement | null>(null);
const panelRef = useRef<HTMLDivElement | null>(null);
const cancelRef = useRef<HTMLButtonElement | null>(null);
const titleId = useId();
const descId = useId();
const notifyId = useId();
const close = useCallback(() => setOpen(false), []);
const publish = useCallback(() => {
setStatus(
notify
? "Published 3 changes. The #design channel was notified."
: "Published 3 changes. Notifications were skipped."
);
setOpen(false);
}, [notify]);
useEffect(() => {
if (!open) return;
const previouslyFocused = document.activeElement as HTMLElement | null;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
return;
}
if (event.key !== "Tab") return;
const panel = panelRef.current;
if (!panel) return;
const nodes = Array.from(
panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
).filter((el) => el.offsetParent !== null || el === document.activeElement);
if (nodes.length === 0) return;
const first = nodes[0];
const last = nodes[nodes.length - 1];
const active = document.activeElement;
const inside = panel.contains(active);
if (event.shiftKey) {
if (active === first || !inside) {
event.preventDefault();
last.focus();
}
} else if (active === last || !inside) {
event.preventDefault();
first.focus();
}
};
document.addEventListener("keydown", onKeyDown);
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const raf = window.requestAnimationFrame(() => {
cancelRef.current?.focus();
});
return () => {
document.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = prevOverflow;
window.cancelAnimationFrame(raf);
previouslyFocused?.focus?.();
};
}, [open, close]);
return (
<section className="relative w-full overflow-hidden bg-slate-50 px-5 py-20 text-slate-900 sm:px-8 sm:py-28 dark:bg-slate-950 dark:text-slate-100">
<style>{`
@keyframes mbasic-float {
0%, 100% { transform: translate3d(0, 0, 0) scale(1); }
50% { transform: translate3d(0, -26px, 0) scale(1.06); }
}
@keyframes mbasic-drift {
0%, 100% { transform: translate3d(0, 0, 0); }
50% { transform: translate3d(22px, 14px, 0); }
}
.mbasic-orb-a { animation: mbasic-float 13s ease-in-out infinite; }
.mbasic-orb-b { animation: mbasic-drift 17s ease-in-out infinite; }
@media (prefers-reduced-motion: reduce) {
.mbasic-orb-a, .mbasic-orb-b { animation: none !important; }
}
`}</style>
<div
aria-hidden="true"
className="mbasic-orb-a pointer-events-none absolute -left-24 -top-24 h-72 w-72 rounded-full bg-indigo-400/25 blur-3xl dark:bg-indigo-500/20"
/>
<div
aria-hidden="true"
className="mbasic-orb-b pointer-events-none absolute -bottom-32 -right-16 h-80 w-80 rounded-full bg-violet-400/20 blur-3xl dark:bg-violet-500/15"
/>
<div className="relative mx-auto max-w-2xl">
<div className="flex flex-col items-start gap-4">
<span className="inline-flex items-center gap-2 rounded-full border border-slate-200 bg-white/70 px-3 py-1 text-xs font-medium tracking-wide text-slate-600 backdrop-blur dark:border-slate-800 dark:bg-slate-900/60 dark:text-slate-300">
<span className="h-1.5 w-1.5 rounded-full bg-emerald-500" />
Design system • v2.4
</span>
<h2 className="text-3xl font-semibold tracking-tight sm:text-4xl">
Ship your component changes with confidence
</h2>
<p className="max-w-xl text-base leading-relaxed text-slate-600 dark:text-slate-400">
Review the diff, confirm the blast radius, then publish. Open the dialog
to see an accessible, keyboard-navigable modal with a focus trap,
scroll lock, and Escape-to-dismiss.
</p>
</div>
<div className="mt-8 rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-wrap items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-slate-900 dark:text-slate-100">
3 pending changes
</p>
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
Button, Modal, and Tooltip are ready for release.
</p>
</div>
<button
ref={triggerRef}
type="button"
onClick={() => setOpen(true)}
aria-haspopup="dialog"
className="inline-flex items-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm 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 dark:focus-visible:ring-offset-slate-900"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
className="h-4 w-4"
>
<path
d="M10 3.5v9M10 3.5 6.5 7M10 3.5 13.5 7"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 13.5v1.5A1.5 1.5 0 0 0 5.5 16.5h9a1.5 1.5 0 0 0 1.5-1.5v-1.5"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
Review & publish
</button>
</div>
<p
role="status"
aria-live="polite"
className="mt-4 min-h-5 text-sm font-medium text-emerald-600 dark:text-emerald-400"
>
{status}
</p>
</div>
</div>
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-50 flex items-end justify-center p-4 sm:items-center sm:p-6">
<motion.div
aria-hidden="true"
onClick={close}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0 : 0.2 }}
className="absolute inset-0 bg-slate-950/60 backdrop-blur-sm"
/>
<motion.div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
aria-describedby={descId}
initial={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: 12 }
}
animate={
reduceMotion ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }
}
exit={
reduceMotion
? { opacity: 0 }
: { opacity: 0, scale: 0.96, y: 12 }
}
transition={{
duration: reduceMotion ? 0 : 0.24,
ease: [0.16, 1, 0.3, 1],
}}
className="relative w-full max-w-md overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-2xl dark:border-slate-800 dark:bg-slate-900"
>
<div className="flex items-start gap-4 border-b border-slate-200 p-6 dark:border-slate-800">
<span className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full bg-indigo-100 text-indigo-600 dark:bg-indigo-500/15 dark:text-indigo-400">
<svg
aria-hidden="true"
viewBox="0 0 24 24"
fill="none"
className="h-5 w-5"
>
<path
d="M12 3v11M12 3 7.5 7.5M12 3l4.5 4.5"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M4 15v3a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-3"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</span>
<div className="min-w-0 flex-1">
<h3
id={titleId}
className="text-lg font-semibold text-slate-900 dark:text-slate-100"
>
Publish to production
</h3>
<p
id={descId}
className="mt-1 text-sm leading-relaxed text-slate-600 dark:text-slate-400"
>
You're about to push 3 component changes to the live
design system. They'll roll out to 214 downstream
projects immediately.
</p>
</div>
<button
type="button"
onClick={close}
aria-label="Close dialog"
className="-mr-1.5 -mt-1.5 shrink-0 rounded-lg p-1.5 text-slate-400 transition hover:bg-slate-100 hover:text-slate-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 dark:hover:bg-slate-800 dark:hover:text-slate-300"
>
<svg
aria-hidden="true"
viewBox="0 0 20 20"
fill="none"
className="h-5 w-5"
>
<path
d="M6 6l8 8M14 6l-8 8"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
</button>
</div>
<div className="space-y-4 p-6">
<ul className="space-y-2 rounded-xl bg-slate-50 p-4 text-sm dark:bg-slate-800/50">
{[
{ name: "Button", note: "focus ring contrast fix" },
{ name: "Modal", note: "focus trap + scroll lock" },
{ name: "Tooltip", note: "delay + placement props" },
].map((item) => (
<li
key={item.name}
className="flex items-center justify-between gap-3"
>
<span className="font-medium text-slate-800 dark:text-slate-200">
{item.name}
</span>
<span className="truncate text-slate-500 dark:text-slate-400">
{item.note}
</span>
</li>
))}
</ul>
<label
htmlFor={notifyId}
className="flex cursor-pointer items-start gap-3 text-sm text-slate-700 dark:text-slate-300"
>
<input
id={notifyId}
type="checkbox"
checked={notify}
onChange={(event) => setNotify(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded border-slate-300 text-indigo-600 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-500 focus-visible:ring-offset-2 focus-visible:ring-offset-white dark:border-slate-600 dark:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
/>
<span>
Notify the{" "}
<span className="font-medium text-slate-900 dark:text-slate-100">
#design
</span>{" "}
channel when the release lands.
</span>
</label>
</div>
<div className="flex flex-col-reverse gap-3 border-t border-slate-200 p-6 sm:flex-row sm:justify-end dark:border-slate-800">
<button
ref={cancelRef}
type="button"
onClick={close}
className="inline-flex items-center justify-center rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:bg-slate-50 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:bg-slate-900 dark:text-slate-200 dark:hover:bg-slate-800 dark:focus-visible:ring-offset-slate-900"
>
Cancel
</button>
<button
type="button"
onClick={publish}
className="inline-flex items-center justify-center gap-2 rounded-xl bg-indigo-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm 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 dark:focus-visible:ring-offset-slate-900"
>
Publish now
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</section>
);
}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 →
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

Scroll Modal
Originalmodal with long scrollable body

Steps Modal
Originalmulti-step modal flow

Command Palette Modal
Originalcommand palette modal with search

