Loading…
Loading…
An overlay window that requires user interaction before returning to the main content.
The Dialog (also called a modal) is an overlay window that appears on top of the main content, requiring user interaction before they can return to the underlying page. It demands attention — and that's exactly why you should use it sparingly.
Dialogs interrupt the user's flow. Every time you open one, you're saying: "Stop what you're doing and deal with this first." That's sometimes necessary (confirming a destructive action, collecting required input), but it's frequently overused. If the content can live inline on the page, it should.
When to use a Dialog:
When NOT to use a Dialog:
Enhance your dialog's visual treatment with our Shadow Generator for depth, the Glassmorphism Generator for frosted-glass backdrops, and the Transition Generator for smooth open/close animations.
| Variant | Purpose | Typical Size |
|---|---|---|
| Alert Dialog | Confirms a user action, especially destructive ones. Has a clear cancel + confirm button pair. | Small (400–480px wide) |
| Form Dialog | Contains a short form (1–5 fields) for focused data entry. | Medium (480–560px wide) |
| Full-screen Dialog | Complex content that needs maximum space (e.g., rich text editor, image cropper). | Full viewport on mobile, max ~900px on desktop |
| Drawer-style Dialog | Slides in from the side rather than appearing centered. See Drawer. | 320–480px wide, full height |
| Nested Dialog | A dialog opened from within another dialog. Avoid if at all possible — stacking modals is a UX anti-pattern. | Same as parent |
| Non-modal Dialog | Does not block interaction with the page behind it. Think chat widgets or sticky panels. | Variable |
| Size | Max Width | Use Case |
|---|---|---|
| Small | 400px | Confirmations, simple alerts |
| Medium | 560px | Short forms, settings panels |
| Large | 720px | Complex content, data previews |
| Full | 100vw (with padding) | Mobile-first, image viewers |
Use the Animation Generator to craft entrance/exit animations for your dialog.
| Property | Type | Default | Description |
|---|---|---|---|
open | boolean | false | Controls whether the dialog is visible |
onClose | () => void | — | Callback when the dialog is dismissed (backdrop click, Escape key, close button) |
size | 'sm' | 'md' | 'lg' | 'full' | 'md' | Max-width of the dialog panel |
closeOnBackdropClick | boolean | true | Whether clicking the backdrop dismisses the dialog |
closeOnEscape | boolean | true | Whether pressing Escape dismisses the dialog |
initialFocus | RefObject<HTMLElement> | — | Element to focus when the dialog opens (defaults to first focusable element) |
returnFocus | boolean | true | Return focus to the trigger element on close |
role | 'dialog' | 'alertdialog' | 'dialog' | Use alertdialog for confirmations that require acknowledgment |
aria-labelledby | string | — | ID of the dialog title element |
aria-describedby | string | — | ID of the dialog description element |
preventScroll | boolean | true | Prevents body scroll when dialog is open |
| Token Category | Token Example | Dialog Usage |
|---|---|---|
| Color – Surface | --color-surface-elevated | Dialog panel background |
| Color – Backdrop | --color-overlay (rgba(0,0,0,0.5)) | Semi-transparent backdrop. Try Glassmorphism Generator for frosted glass. |
| Border Radius | --radius-xl (16px) | Dialog corners. Preview with Border Radius Generator. |
| Shadow | --shadow-2xl | Dialog elevation. Configure with Shadow Generator. |
| Spacing – Padding | --space-6 (24px) | Internal padding of the dialog body |
| Spacing – Gap | --space-4 (16px) | Gap between title, body, and footer |
| Typography | --font-size-lg, --font-weight-semibold | Dialog title styling |
| Transition | --duration-normal (200ms) | Open/close animation duration |
| Z-index | --z-modal (50) | Stacking above page content |
For a comprehensive look at token architecture, read our Design Tokens Complete Guide.
| State | Visual Behavior | Implementation |
|---|---|---|
| Closed | Not rendered or rendered with display: none. No DOM presence is ideal for performance. | Remove from DOM or use hidden attribute |
| Opening | Backdrop fades in (opacity 0→1). Panel scales up or slides in. | CSS transition or animation. Use Transition Generator for values. |
| Open | Backdrop visible. Panel centered (or positioned). Focus trapped inside. Body scroll locked. | aria-modal="true", inert on background content |
| Closing | Reverse of opening animation. Focus returns to trigger. | Delay unmounting until animation completes |
| Nested | Second backdrop layers on top. Each dialog manages its own focus trap. | Maintain a dialog stack — close inner before outer |
rgba(0,0,0,0.5))backdrop-filter: blur(8px)). Creates depth but impacts performance on low-end devices.| Criterion | Level | Requirement |
|---|---|---|
| SC 1.3.1 Info and Relationships | A | Dialog must be marked with role="dialog" or use the native <dialog> element |
| SC 2.1.2 No Keyboard Trap | A | Users must be able to close the dialog via keyboard (Escape key) |
| SC 2.4.3 Focus Order | A | Focus must move into the dialog when it opens and return to the trigger when it closes |
| SC 4.1.2 Name, Role, Value | A | Dialog must have an accessible name via aria-labelledby or aria-label |
role="dialog" — Standard dialog. Use the native <dialog> element when possible (it gives you a lot for free).role="alertdialog" — For dialogs that require user acknowledgment (confirmations, warnings). Screen readers announce these more urgently.aria-modal="true" — Tells assistive tech that content behind the dialog is inert. Essential.aria-labelledby — Point to the dialog's title heading.aria-describedby — Point to the dialog's description text (optional but recommended).tabindex="-1").| Key | Action |
|---|---|
| Escape | Closes the dialog (unless role="alertdialog" requires explicit choice) |
| Tab | Cycles through focusable elements within the dialog |
| Shift + Tab | Cycles backwards through focusable elements |
inert AttributeApply inert to all page content behind the dialog. This is superior to manual aria-hidden management because it also prevents mouse and keyboard interaction with background elements.
<main inert><!-- page content --></main>
<dialog open aria-labelledby="dialog-title" aria-modal="true">
<!-- dialog content -->
</dialog>
For more on accessible overlays, see our ARIA Attributes Guide and Keyboard Accessibility Guide.
<!-- Dialog using native <dialog> element -->
<button type="button" id="open-dialog" aria-haspopup="dialog">
Delete project
</button>
<dialog
id="confirm-dialog"
aria-labelledby="dialog-title"
aria-describedby="dialog-desc"
>
<header>
<h2 id="dialog-title">Delete project?</h2>
<button type="button" aria-label="Close" onclick="this.closest('dialog').close()">
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z"/>
</svg>
</button>
</header>
<p id="dialog-desc">
This will permanently delete <strong>My Project</strong> and all its data.
This action cannot be undone.
</p>
<footer>
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">
Cancel
</button>
<button type="button" class="btn btn-destructive">
Delete project
</button>
</footer>
</dialog>
<script>
const btn = document.getElementById("open-dialog");
const dialog = document.getElementById("confirm-dialog");
btn.addEventListener("click", () => dialog.showModal());
</script>import { useRef, useEffect, type ReactNode } from "react";
import { createPortal } from "react-dom";
interface DialogProps {
open: boolean;
onClose: () => void;
title: string;
description?: string;
children: ReactNode;
size?: "sm" | "md" | "lg";
}
export default function Dialog({
open,
onClose,
title,
description,
children,
size = "md",
}: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
} else if (!open && dialog.open) {
dialog.close();
}
}, [open]);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const handleClose = () => onClose();
dialog.addEventListener("close", handleClose);
return () => dialog.removeEventListener("close", handleClose);
}, [onClose]);
const sizeClass = {
sm: "max-w-[400px]",
md: "max-w-[560px]",
lg: "max-w-[720px]",
}[size];
return createPortal(
<dialog
ref={dialogRef}
className={`dialog ${sizeClass}`}
aria-labelledby="dialog-title"
aria-describedby={description ? "dialog-desc" : undefined}
onClick={(e) => {
if (e.target === dialogRef.current) onClose();
}}
>
<div className="dialog-panel">
<header className="dialog-header">
<h2 id="dialog-title">{title}</h2>
<button type="button" aria-label="Close" onClick={onClose}>✕</button>
</header>
{description && <p id="dialog-desc">{description}</p>}
<div className="dialog-body">{children}</div>
</div>
</dialog>,
document.body,
);
}
// Usage
function App() {
const [open, setOpen] = useState(false);
return (
<>
<button onClick={() => setOpen(true)}>Delete project</button>
<Dialog
open={open}
onClose={() => setOpen(false)}
title="Delete project?"
description="This action cannot be undone."
size="sm"
>
<footer>
<button onClick={() => setOpen(false)}>Cancel</button>
<button className="btn-destructive" onClick={handleDelete}>
Delete project
</button>
</footer>
</Dialog>
</>
);
}| Feature | Material 3 | Shadcn/ui | Radix | Ant Design |
|---|---|---|---|---|
| Element | Custom <div> with portal | <dialog> via Radix | <div> with portal + focus trap | Custom <div> with portal |
| Focus trap | Built-in | Via Radix primitives | Excellent — FocusTrap + FocusScope | Built-in |
| Animation | Material motion system | CSS transitions (Tailwind) | BYO animation | CSS transitions (Ant Motion) |
| Nested dialogs | Supported | Supported via Radix | Full stack management | Supported but discouraged |
| Alert variant | Separate AlertDialog | AlertDialog (separate component) | AlertDialog primitive | Modal.confirm() static method |
| Close on backdrop | Configurable | Default true | Configurable | maskClosable prop |
| Scroll lock | document.body overflow hidden | RemoveScroll from Radix | RemoveScroll component | Body overflow hidden |
Radix sets the standard for dialog accessibility. Its Dialog primitive handles focus trapping, scroll locking, and portal rendering with the inert attribute on background content. If you're building a dialog from scratch, study Radix's implementation first.
Material 3 separates "Basic Dialog" (informational) from "Full-screen Dialog" (complex tasks). The full-screen variant replaces the entire view on mobile — essentially a temporary page — which solves the "dialog inside dialog" problem elegantly.
Shadcn/ui wraps Radix primitives with Tailwind styling. The AlertDialog is a separate component from Dialog, which enforces the correct role="alertdialog" and prevents developers from accidentally using the wrong pattern for confirmations.
Ant Design offers Modal.confirm(), Modal.info(), and similar static methods that let you open a dialog imperatively (without JSX). This is convenient for one-off confirmations but makes testing harder.
The native HTML <dialog> element has reached excellent browser support (97%+ globally as of 2026). It provides showModal() for modal behavior and show() for non-modal — consider using it as your base rather than a <div> portal approach.