Dialog
A modal overlay rendered via portal. Supports Escape to close, backdrop click to dismiss, body scroll lock, and four sizes.
AccessibleDark ModePortal4 SizesCompound
01
Theme Preview
Static dialog panel rendered inline — forced light and dark styling for direct comparison.
Light
Delete item?
This action is permanent and cannot be undone.
Dark
Delete item?
This action is permanent and cannot be undone.
02
Live Demo
Click a button to open the corresponding dialog.
03
Code Snippet
src/ui/Dialog.tsx
import { Dialog } from "@/ui/Dialog"; import { Button } from "@/ui/Button"; // Confirm dialog const [open, setOpen] = useState(false); <Button onClick={() => setOpen(true)}>Delete item</Button> <Dialog.Root open={open} onClose={() => setOpen(false)} size="sm"> <Dialog.Header> <Dialog.Title>Delete item?</Dialog.Title> <Dialog.Description> This action is permanent and cannot be undone. </Dialog.Description> </Dialog.Header> <Dialog.Footer> <Button variant="ghost" onClick={() => setOpen(false)}>Cancel</Button> <Button variant="destructive" onClick={() => setOpen(false)}>Delete</Button> </Dialog.Footer> </Dialog.Root> // Sizes: "sm" | "md" | "lg" | "xl" <Dialog.Root open={open} onClose={() => setOpen(false)} size="lg"> ... </Dialog.Root>
Flat exports seperti DialogHeader, DialogContent, dan lainnya tetap didukung untuk migrasi bertahap.
04
Copy-Paste (Single File)
Versi ini sudah self-contained, termasuk helper class merge dan animated mount, jadi minim setup saat dipindahkan.
Dialog.tsx
"use client"; import { useEffect, useRef, useState, type HTMLAttributes, type ReactNode } from "react"; import { createPortal } from "react-dom"; type ClassValue = string | false | null | undefined; const cn = (...classes: ClassValue[]) => classes.filter(Boolean).join(" "); export type DialogSize = "sm" | "md" | "lg" | "xl"; export interface DialogProps { open: boolean; onClose: () => void; size?: DialogSize; children: ReactNode; className?: string; } const SIZE_CLASSES: Record<DialogSize, string> = { sm: "max-w-sm", md: "max-w-md", lg: "max-w-lg", xl: "max-w-xl", }; function useAnimatedMount(open: boolean, durationMs = 200) { const [mounted, setMounted] = useState(open); const [visible, setVisible] = useState(open); useEffect(() => { if (open) { setMounted(true); requestAnimationFrame(() => setVisible(true)); return; } setVisible(false); const timer = window.setTimeout(() => setMounted(false), durationMs); return () => window.clearTimeout(timer); }, [open, durationMs]); return { mounted, visible }; } function DialogHeader({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("px-6 pt-6 pb-4", className)} {...props}>{children}</div>; } function DialogTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) { return <h2 className={cn("pr-8 text-lg font-semibold text-slate-900", className)} {...props}>{children}</h2>; } function DialogDescription({ className, children, ...props }: HTMLAttributes<HTMLParagraphElement>) { return <p className={cn("mt-1.5 text-sm leading-relaxed text-slate-500", className)} {...props}>{children}</p>; } function DialogContent({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("px-6 py-2", className)} {...props}>{children}</div>; } function DialogFooter({ className, children, ...props }: HTMLAttributes<HTMLDivElement>) { return <div className={cn("flex items-center justify-end gap-3 border-t border-slate-100 px-6 py-4", className)} {...props}>{children}</div>; } function DialogRoot({ open, onClose, size = "md", children, className }: DialogProps) { const overlayRef = useRef<HTMLDivElement>(null); const { mounted, visible } = useAnimatedMount(open, 200); useEffect(() => { if (!open) return; const onKeyDown = (event: KeyboardEvent) => { if (event.key === "Escape") onClose(); }; document.addEventListener("keydown", onKeyDown); document.body.style.overflow = "hidden"; return () => { document.removeEventListener("keydown", onKeyDown); document.body.style.overflow = ""; }; }, [open, onClose]); if (!mounted) return null; return createPortal( <div ref={overlayRef} className="fixed inset-0 z-50 flex items-center justify-center p-4" onClick={(event) => { if (event.target === overlayRef.current) onClose(); }} > <div className={cn("absolute inset-0 bg-black/50 backdrop-blur-xs transition-opacity duration-200", visible ? "opacity-100" : "opacity-0")} /> <div role="dialog" aria-modal="true" className={cn( "relative w-full rounded-2xl border border-slate-200 bg-white shadow-2xl shadow-black/20 transition-all duration-200", visible ? "scale-100 opacity-100" : "scale-95 opacity-0", SIZE_CLASSES[size], className )} > <button onClick={onClose} aria-label="Close dialog" className="absolute right-4 top-4 rounded-lg p-1.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600" > × </button> {children} </div> </div>, document.body ); } type DialogCompound = typeof DialogRoot & { Root: typeof DialogRoot; Header: typeof DialogHeader; Title: typeof DialogTitle; Description: typeof DialogDescription; Content: typeof DialogContent; Footer: typeof DialogFooter; }; export const Dialog = Object.assign(DialogRoot, { Root: DialogRoot, Header: DialogHeader, Title: DialogTitle, Description: DialogDescription, Content: DialogContent, Footer: DialogFooter, }) as DialogCompound;
05
Props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controls dialog visibility. |
onClose | () => void | — | Called when Escape is pressed, backdrop is clicked, or the × button is clicked. |
size | "sm" | "md" | "lg" | "xl" | "md" | Controls max-width of the dialog panel. |
children | ReactNode | — | Dialog content, typically composed with sub-components. |
className | string | — | Extra classes applied to the dialog panel. |