Modal Dialog

Accessible confirm dialog rendered in a portal, with Esc and scroll lock.

Installation

  1. Install the dependencies

    $ npm i motion lucide-react
  2. Copy the source into components/ui/modal-dialog.tsx

    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&apos;t be undone.
          </Modal>
        </>
      );
    }
    
  3. Use it

    The file exports the component and a Demo showing typical usage. Import whichever you need.