Dialog
A modal or side sheet with focus trap, scroll lock and focus return.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/dialog.jsoncomponents/examples/dialog-demo.tsx
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
export default function DialogDemo() {
const [modal, setModal] = React.useState(false);
const [sheet, setSheet] = React.useState(false);
return (
<div className="flex gap-3">
<Button onClick={() => setModal(true)}>Open dialog</Button>
<Button variant="secondary" onClick={() => setSheet(true)}>Open sheet</Button>
<Dialog
open={modal}
onOpenChange={setModal}
title="Delete project?"
description="This removes 'Q3 launch' and its 14 files for everyone. This can't be undone."
footer={
<>
<Button variant="ghost" onClick={() => setModal(false)}>Cancel</Button>
<Button variant="destructive" onClick={() => setModal(false)}>Delete project</Button>
</>
}
/>
<Dialog open={sheet} onOpenChange={setSheet} side="right" title="Edit member" description="Changes apply immediately." footer={<Button onClick={() => setSheet(false)}>Save</Button>}>
<label className="block text-sm">
<span className="mb-1.5 block text-muted-foreground">Name</span>
<input defaultValue="Ada Lovelace" className="h-10 w-full rounded-md border border-input bg-transparent px-3 outline-none focus:border-foreground/40" />
</label>
</Dialog>
</div>
);
}When to use
- Confirming destructive actions, short forms, and focused tasks.
- Side sheets for editing a record without leaving the list.
When not to use
- Long flows: use a page.
- Non-blocking info: use a popover or toast.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/dialog.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/dialog.tsx"use client"; import * as React from "react"; import { createPortal } from "react-dom"; import { AnimatePresence, motion } from "motion/react"; import { X } from "lucide-react"; import { cn } from "@/lib/utils"; export interface DialogProps { open: boolean; onOpenChange: (open: boolean) => void; title: React.ReactNode; description?: React.ReactNode; children?: React.ReactNode; footer?: React.ReactNode; /** "center" for a modal, "right" or "bottom" for a sheet. */ side?: "center" | "right" | "bottom"; className?: string; } const noop = () => () => {}; const FOCUSABLE = 'a[href],button:not([disabled]),input:not([disabled]),select,textarea,[tabindex]:not([tabindex="-1"])'; const variants = { center: { initial: { opacity: 0, scale: 0.96, y: 8 }, animate: { opacity: 1, scale: 1, y: 0 }, exit: { opacity: 0, scale: 0.97, y: 4 } }, right: { initial: { x: "100%" }, animate: { x: 0 }, exit: { x: "100%" } }, bottom: { initial: { y: "100%" }, animate: { y: 0 }, exit: { y: "100%" } }, }; /** Modal dialog or sheet with focus trap, scroll lock and focus return. */ export function Dialog({ open, onOpenChange, title, description, children, footer, side = "center", className }: DialogProps) { const id = React.useId(); const mounted = React.useSyncExternalStore(noop, () => true, () => false); const panelRef = React.useRef<HTMLDivElement>(null); React.useEffect(() => { if (!open) return; const previous = document.activeElement as HTMLElement | null; const overflow = document.body.style.overflow; document.body.style.overflow = "hidden"; panelRef.current?.querySelector<HTMLElement>(FOCUSABLE)?.focus(); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onOpenChange(false); if (e.key !== "Tab" || !panelRef.current) return; const nodes = [...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)]; if (!nodes.length) return; const first = nodes[0]; const last = nodes[nodes.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; document.addEventListener("keydown", onKey); return () => { document.body.style.overflow = overflow; document.removeEventListener("keydown", onKey); previous?.focus(); }; }, [open, onOpenChange]); if (!mounted) return null; const v = variants[side]; return createPortal( <AnimatePresence> {open && ( <div className={cn( "fixed inset-0 z-[150] flex", side === "center" && "items-center justify-center p-4", side === "right" && "justify-end", side === "bottom" && "items-end", )} > <motion.div className="absolute inset-0 bg-black/55 backdrop-blur-[2px]" initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => onOpenChange(false)} /> <motion.div ref={panelRef} role="dialog" aria-modal="true" aria-labelledby={`${id}-title`} aria-describedby={description ? `${id}-desc` : undefined} initial={v.initial} animate={v.animate} exit={v.exit} transition={{ type: "spring", bounce: side === "center" ? 0.12 : 0, duration: 0.38 }} className={cn( "relative flex flex-col border border-border bg-background shadow-2xl", side === "center" && "max-h-[85dvh] w-full max-w-md rounded-xl", side === "right" && "h-full w-full max-w-md border-y-0 border-r-0", side === "bottom" && "max-h-[85dvh] w-full rounded-t-xl border-x-0 border-b-0", className, )} > <header className="flex items-start justify-between gap-4 border-b border-border px-5 py-4"> <div> <h2 id={`${id}-title`} className="font-semibold">{title}</h2> {description && <p id={`${id}-desc`} className="mt-1 text-sm text-muted-foreground">{description}</p>} </div> <button type="button" aria-label="Close" onClick={() => onOpenChange(false)} className="grid size-8 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground"> <X className="size-4" /> </button> </header> {children && <div className="flex-1 overflow-y-auto px-5 py-4 text-sm">{children}</div>} {footer && <footer className="flex justify-end gap-2 border-t border-border px-5 py-3">{footer}</footer>} </motion.div> </div> )} </AnimatePresence>, document.body, ); }
Usage
Import it from @/components/ui/dialog after installing.
example.tsx
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Dialog } from "@/components/ui/dialog";
export function DeleteProject({ onDelete }: { onDelete: () => void }) {
const [open, setOpen] = React.useState(false);
return (
<>
<Button variant="destructive" onClick={() => setOpen(true)}>Delete</Button>
<Dialog
open={open}
onOpenChange={setOpen}
title="Delete project?"
description="This can't be undone."
footer={<><Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button><Button variant="destructive" onClick={onDelete}>Delete</Button></>}
/>
</>
);
}API reference
Dialog
A modal or sheet rendered in a portal, with focus trap, scroll lock and focus return.
| Prop | Type | Default | Description |
|---|---|---|---|
| open / onOpenChange | boolean / (open: boolean) => void | – | Controlled visibility. |
| title / description | ReactNode | – | Linked to the dialog for screen readers. |
| children / footer | ReactNode | – | Body and action row. |
| side | "center" | "right" | "bottom" | "center" | Modal, right sheet or bottom sheet. |
Keyboard interactions
| Keys | Action |
|---|---|
| Esc | Close |
| TaborShift+Tab | Cycle focus inside the dialog |
Accessibility
- aria-modal with aria-labelledby and aria-describedby.
- Focus moves to the first control on open and returns to the trigger on close.
Motion
- The modal scales up from 96%; sheets slide from their edge.
Responsive behavior
- The modal is full width minus 32px on phones; use side="bottom" for phone-friendly sheets.
Performance
- Nothing renders while closed.
Notes for AI
- Put the destructive button last in the footer.
This page as Markdown, for assistants and coding agents:
/components/dialog/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt