Modal Dialog
Accessible confirm dialog rendered in a portal, with Esc and scroll lock.
components/ui/modal-dialog.tsx
"use client";
import { AnimatePresence, motion } from "motion/react";
import { Trash2, X } from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import { createPortal } from "react-dom";
type ModalProps = {
open: boolean;
onClose: () => void;
title: string;
children: ReactNode;
footer?: ReactNode;
};
export function Modal({ open, onClose, title, children, footer }: ModalProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
useEffect(() => {
if (!open) return;
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
window.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prev;
window.removeEventListener("keydown", onKey);
};
}, [open, onClose]);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && (
<div className="fixed inset-0 z-[100] grid place-items-center p-4">
<motion.div className="absolute inset-0 bg-zinc-900/40 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} />
<motion.div
role="dialog"
aria-modal="true"
aria-label={title}
initial={{ opacity: 0, scale: 0.94, y: 12 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ type: "spring", stiffness: 380, damping: 30 }}
className="relative w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl"
>
<button onClick={onClose} aria-label="Close" className="absolute right-4 top-4 grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700">
<X className="size-4" />
</button>
<h2 className="pr-8 text-lg font-semibold text-zinc-900">{title}</h2>
<div className="mt-2 text-sm leading-relaxed text-zinc-600">{children}</div>
{footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>}
</motion.div>
</div>
)}
</AnimatePresence>,
document.body,
);
}
export default function Demo() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 rounded-lg bg-rose-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-rose-700">
<Trash2 className="size-4" /> Delete project
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="Delete this project?"
footer={
<>
<button onClick={() => setOpen(false)} className="rounded-lg border border-zinc-200 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50">Cancel</button>
<button onClick={() => setOpen(false)} className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700">Delete</button>
</>
}
>
This permanently removes the project, its deployments and all analytics. This can't be undone.
</Modal>
</>
);
}
Installation
Install the dependencies
$ npm i motion lucide-reactCopy the source into
components/ui/modal-dialog.tsxcomponents/ui/modal-dialog.tsx"use client"; import { AnimatePresence, motion } from "motion/react"; import { Trash2, X } from "lucide-react"; import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; type ModalProps = { open: boolean; onClose: () => void; title: string; children: ReactNode; footer?: ReactNode; }; export function Modal({ open, onClose, title, children, footer }: ModalProps) { const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); useEffect(() => { if (!open) return; const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose(); const prev = document.body.style.overflow; document.body.style.overflow = "hidden"; window.addEventListener("keydown", onKey); return () => { document.body.style.overflow = prev; window.removeEventListener("keydown", onKey); }; }, [open, onClose]); if (!mounted) return null; return createPortal( <AnimatePresence> {open && ( <div className="fixed inset-0 z-[100] grid place-items-center p-4"> <motion.div className="absolute inset-0 bg-zinc-900/40 backdrop-blur-sm" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={onClose} /> <motion.div role="dialog" aria-modal="true" aria-label={title} initial={{ opacity: 0, scale: 0.94, y: 12 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.96, y: 8 }} transition={{ type: "spring", stiffness: 380, damping: 30 }} className="relative w-full max-w-md rounded-2xl bg-white p-6 shadow-2xl" > <button onClick={onClose} aria-label="Close" className="absolute right-4 top-4 grid size-8 place-items-center rounded-lg text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"> <X className="size-4" /> </button> <h2 className="pr-8 text-lg font-semibold text-zinc-900">{title}</h2> <div className="mt-2 text-sm leading-relaxed text-zinc-600">{children}</div> {footer && <div className="mt-6 flex justify-end gap-2">{footer}</div>} </motion.div> </div> )} </AnimatePresence>, document.body, ); } export default function Demo() { const [open, setOpen] = useState(false); return ( <> <button onClick={() => setOpen(true)} className="inline-flex items-center gap-2 rounded-lg bg-rose-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-rose-700"> <Trash2 className="size-4" /> Delete project </button> <Modal open={open} onClose={() => setOpen(false)} title="Delete this project?" footer={ <> <button onClick={() => setOpen(false)} className="rounded-lg border border-zinc-200 px-4 py-2 text-sm font-medium text-zinc-700 hover:bg-zinc-50">Cancel</button> <button onClick={() => setOpen(false)} className="rounded-lg bg-rose-600 px-4 py-2 text-sm font-medium text-white hover:bg-rose-700">Delete</button> </> } > This permanently removes the project, its deployments and all analytics. This can't be undone. </Modal> </> ); }Use it
The file exports the component and a
Demoshowing typical usage. Import whichever you need.