Component · Containers & Overlays
Dialog
Overview
A modal overlay that interrupts the current flow until the user resolves it — the page behind it becomes inert. Dialog's modal behavior — focus trapping, focus restoration, background inertness, and keyboard dismissal — is specified under Focus trap and restoration.
Anatomy
Overlay (backdrop) → Container → Title → Description → Body → Actions → Close control.
- Overlay — a scrim behind the dialog that visually and functionally separates it from the page. See Overlay scrim for the explicit value the scrim uses.
- Container — the dialog surface itself.
- Title — required; names what the dialog is about (Heading 4 recipe).
- Description — optional supporting sentence under the title (Body small,
--muted-foreground). - Body — the dialog's main content; free-form.
- Actions — a footer row of one or more Buttons; see Action ordering.
- Close control — an optional
✕icon-button, top-right. Optional becauseEscapeand the Actions row may already provide an exit; include it when the dialog has no clear default "cancel" action otherwise (e.g. a pure informational dialog with only one "OK" action).
Live Example
Standard dialog — a short form
Destructive confirmation — overlay-click disabled
Long content — only the body scrolls
Code Example
"use client";
import { X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogBody,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
/**
* Live Dialog example — open one and the page behind it goes inert: `Tab` and
* `Shift+Tab` cycle only inside the dialog, `Escape` closes it, and focus
* returns to the trigger on close.
*
* - Standard dialog: a short form at the default size. Overlay-click and
* `Escape` both dismiss; there is no `✕` because Cancel is a clear exit.
* - Destructive confirmation: `role="alertdialog"`, `dismissible={false}` so an
* accidental overlay click can't discard the decision, Title names the
* action, Description states the consequence, and Cancel (first in the DOM,
* so it takes initial focus) is paired with a `destructive` confirm.
* - Long content: Title and the footer stay fixed while only the body scrolls;
* the container never grows past the viewport.
*/
export function DialogShowcase() {
return (
<div className="flex flex-col gap-8">
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Standard dialog — a short form
</h3>
<Dialog>
<DialogTrigger>
<Button variant="outline">Rename workspace</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Rename workspace</DialogTitle>
<DialogDescription>
This changes the name everywhere it appears for your team.
</DialogDescription>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-2">
<Label htmlFor="rename-workspace">Workspace name</Label>
<Input id="rename-workspace" defaultValue="Acme Inc." />
</div>
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<DialogClose>
<Button>Save</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Destructive confirmation — overlay-click disabled
</h3>
<Dialog role="alertdialog" dismissible={false}>
<DialogTrigger>
<Button variant="destructive">Delete project</Button>
</DialogTrigger>
<DialogContent size="small">
<DialogHeader>
<DialogTitle>Delete project?</DialogTitle>
<DialogDescription>
This permanently deletes “Migration plan” and all of its tasks.
This can’t be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<DialogClose>
<Button variant="destructive">Delete</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
<section className="flex flex-col gap-3">
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
Long content — only the body scrolls
</h3>
<Dialog>
<DialogTrigger>
<Button variant="outline">Review terms</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Updated terms of service</DialogTitle>
<DialogDescription>
Review the changes below before continuing.
</DialogDescription>
<DialogClose>
<Button
variant="ghost"
size="icon"
aria-label="Close"
className="absolute right-4 top-4"
>
<X />
</Button>
</DialogClose>
</DialogHeader>
<DialogBody>
<div className="flex flex-col gap-4">
{Array.from({ length: 12 }, (_, i) => (
<p key={i}>
Section {i + 1}. The body region scrolls internally once its
content exceeds the available height, while the title,
description, and footer stay pinned in place. The dialog
container itself is capped and never grows to fit long
content.
</p>
))}
</div>
</DialogBody>
<DialogFooter>
<DialogClose>
<Button variant="ghost">Decline</Button>
</DialogClose>
<DialogClose>
<Button>Accept</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
</div>
);
}States
| State | Look |
|---|---|
| Closed | Not rendered |
| Open | Overlay visible, container --background fill, --radius-xl, --shadow-lg |
Usage Guidance
Purpose and interruption threshold
Reserve Dialog for moments that genuinely need to block everything else: a decision that must be made before continuing (a destructive confirmation), a short task that must complete or be explicitly cancelled, or a critical message the user can't be allowed to miss. Every Dialog is friction by design — opening one should be justified by that interruption actually being necessary, not by convenience.
When not to use it
- Supplementary, non-blocking content — that's Popover.
- A single-line clarification — that's Tooltip.
- A focused secondary task that benefits from more page context around it, or a mobile-style bottom-anchored flow — that's Sheet.
- A substantial, multi-step, or deep-linkable task — see When to use a full page instead.
Overlay scrim
No dedicated overlay/scrim token exists in Color. Dialog uses an
explicit value instead of an approximate --foreground-at-opacity lever:
rgb(0 0 0 / 48%)
- The same neutral black scrim is used in both light and dark mode — it doesn't flip per theme
the way a
--foreground-derived value would. - This is an explicit value, not a semantic token. The 48% opacity separates the dialog from the page behind it without obscuring context so completely that the interruption feels jarring.
- A dedicated
--overlayor--scrimsemantic token may be added to Color in a future release; nothing here depends on it.
Dismissal: Escape and overlay click
Escape and overlay-click are documented separately — they are not automatically identical,
and overlay-click dismissal is conditional on the dialog's risk level.
| Dismissal method | Default behavior |
|---|---|
Escape | Closes the dialog by default |
| Click on overlay | Closes standard, low-risk dialogs by default |
Overlay-click dismissal must be disabled (the click does nothing) for:
- Destructive confirmations (see Destructive confirmations)
- Unsaved-change flows, where an accidental outside click would silently discard work
- Critical acknowledgements the user must not be able to dismiss without engaging
- Any other interaction where accidental dismissal creates meaningful risk
When overlay-click dismissal is disabled, an explicit close or cancel action (the Actions row, or the optional Close control) must remain available — disabling overlay-click is never a reason to leave a dialog with no way out.
Escape follows the same risk-based judgment on its own terms: a destructive confirmation or
unsaved-changes flow may also choose to intercept Escape to confirm first, the same kind of
per-instance call Radio Group documents for default selection —
but that's a decision made independently, not inherited automatically from the overlay-click rule
above.
Destructive confirmations
- Title names the action directly ("Delete project?"), not a generic "Are you sure?".
- Description states the consequence, including whether it's reversible.
- Actions row pairs a neutral Cancel with a Destructive-variant confirm button, clearly labeled with the action itself ("Delete", not "OK").
- Default focus lands on Cancel, not the destructive action — the same safe-default reasoning
Radio Group applies to never preselecting a high-stakes
option: an accidental
Enterpress should never confirm something destructive. This focus default is specific to destructive confirmations, not a rule for every Dialog. - Overlay-click dismissal is disabled — see Dismissal: Escape and overlay click.
Action ordering
The primary/confirm action sits at the right end of the Actions row; Cancel (or another lower-emphasis action) sits to its left. This matches the prevailing convention this system's other right-aligned action rows already use and keeps the action a user is most likely to take predictable in position.
Long content and scrolling
Title, Description, and Actions stay fixed; only the Body scrolls internally once its content exceeds the available height. The dialog container itself doesn't grow past its capped size (see Responsive sizing) to accommodate long content.
When to use a full page instead
Use a dedicated page (a Layout "Narrow form" or "App shell" pattern) instead of a Dialog when the content is a multi-step flow, needs its own URL for deep-linking or sharing, or simply needs more room than a bounded modal can reasonably offer without becoming its own scrollable sub-application.
Tokens
Dialog uses existing Monogem semantic tokens to stay consistent with the design system. The one lever that isn't a clean token fit is the Overlay scrim.
| Token | Where used |
|---|---|
--background / --foreground | Container fill + text |
--muted-foreground | Description text |
--border | Container edge, useful on dark mode per Shadows |
--radius-xl | Corner radius — Radius explicitly names "Modals, large surfaces" |
--shadow-lg | Elevation — Shadows names "Modals, dialogs, sheets" |
--ring | Focus ring on interactive content, and on the close control |
--scale-6 | Container padding |
--scale-4 | Gap between Title/Description/Body/Actions; also the mobile inset on each side below --breakpoint-sm |
--breakpoint-sm / --breakpoint-md / --breakpoint-lg | Container max-width — Small / Default / Large (see Responsive sizing) |
The overlay scrim is an explicit rgb(0 0 0 / 48%) value, not a token — see
Overlay scrim.
Do / Don’t
Do
- Reserve Dialog for genuinely blocking moments.
- Default focus to Cancel for any destructive confirmation.
- Keep the Title, Description, and Actions fixed while the Body scrolls.
- Disable overlay-click dismissal for destructive confirmations, unsaved-change flows, and critical acknowledgements, while keeping an explicit close or cancel action available.
Don’t
- Stack a Dialog on top of another open Dialog — resolve or close the first before opening a second.
- Fire a Toast from inside an open Dialog — the page behind it (the Toast viewport included) is inert, so the toast is covered by the scrim and can't be reached or announced. Close the Dialog first, or report inline. See the Toast known limitation.
- Use Dialog for a long, multi-step flow — use a full page.
- Make the destructive action the default-focused button.
- Allow overlay-click dismissal on a destructive confirmation, unsaved-change flow, or critical acknowledgement.
Accessibility
role="dialog"(oralertdialogfor a critical, must-acknowledge message) witharia-modal="true".aria-labelledbyreferences the Title'sid;aria-describedbyreferences the Description'sidwhen present.- Focus trap and restoration as described above — required, not optional, for keyboard and screen-reader users alike.
- The rest of the page is marked inert (e.g.
aria-hidden/inerton background content) while the dialog is open, so assistive tech can't navigate into content the user can't actually reach.
Focus trap and restoration
On open, focus moves into the dialog — onto its first focusable element, or the dialog container
itself when no field inside is an obviously correct first stop. While open, Tab/Shift+Tab
cycle only among the dialog's own focusable elements; focus cannot land on anything in the page
behind it. On close, focus returns to the element that opened the dialog.
This is a hard runtime requirement, not a visual one: keyboard and screen-reader users depend on it.
Responsive Behavior
Three sizes, each capped at one of Grids' existing breakpoint tokens —
reused directly rather than inventing bespoke width values — so the hierarchy is unambiguous:
small < default < large.
| Size | Max-width | Mobile (below --breakpoint-sm) | Use for | Internal scrolling |
|---|---|---|---|---|
| Small | --breakpoint-sm (640px) | Full viewport width, --scale-4 inset each side | Short confirmations, single-message acknowledgements | Body scrolls only if content still exceeds the viewport height at full width |
| Default | --breakpoint-md (768px) | Full viewport width, --scale-4 inset each side | Most dialogs — a short form, a focused task with a few fields | Body scrolls once content exceeds the container's capped height |
| Large | --breakpoint-lg (1024px) | Full viewport width, --scale-4 inset each side | Denser content — multi-field forms, an embedded table or list | Body scrolls once content exceeds the container's capped height |
Every size converges to the same full-width, inset mobile presentation below --breakpoint-sm —
converging toward Sheet's mobile footprint. Version 1 keeps a full-width, inset Dialog at that point rather than converting it to a
Sheet — the two stay distinct components.