Dialog
A modal window over the page. Built on Radix Dialog for focus trap, scroll lock, and a11y; styled with one tv slots recipe and animated with interruptible enter/exit.
Every pattern below is its own block: a live preview in a device frame you can resize (mobile / tablet / desktop, or drag the handle) plus its code. Dialogs cap at md (30rem) by default, the width nearly every dialog should use. Drag any frame narrow to watch the card shrink to fit.
Installation#
Usage#
import {
Dialog, DialogTrigger, DialogContent, DialogIcon, DialogHeader,
DialogFooter, DialogTitle, DialogDescription, DialogClose,
} from "@/components/ui/dialog"
import { UserCircle } from "koala-icons"
import { Button } from "@/components/ui/button"
export function Example() {
return (
<Dialog>
<DialogTrigger asChild>
<Button>Open dialog</Button>
</DialogTrigger>
<DialogContent>
<DialogIcon><UserCircle /></DialogIcon>
<DialogHeader>
<DialogTitle>Edit profile</DialogTitle>
<DialogDescription>
Visible to everyone in your workspace.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<Button>Save changes</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}Anatomy#
import {
Dialog, DialogTrigger, DialogContent, DialogIcon, DialogHeader, DialogTitle,
DialogDescription, DialogStepper, DialogBody, DialogFooter, DialogClose,
} from "@/components/ui/dialog"
<Dialog>
<DialogTrigger />
<DialogContent>
<DialogIcon />
<DialogHeader>
<DialogTitle />
<DialogDescription />
</DialogHeader>
<DialogStepper />
<DialogBody />
<DialogFooter>
<DialogClose />
</DialogFooter>
</DialogContent>
</Dialog>Confirmation#
A destructive confirmation: no header close button (showClose={false}) forces an explicit choice, and the leading icon is tinted red.
Form#
Place labelled fields between DialogHeader and DialogFooter. Land focus on the first field with onOpenAutoFocus; inline validation appears below each field on submit.
Compact density#
Dialogs are compact unless a DensityProvider or density="comfortable" opens them up (20px padding instead of 16px, wider gaps, a larger icon). The content re-provides its density to the header, body and footer so every part stays in sync. Best driven once via DensityProvider; see Density.
Scrollable body#
For tall content (terms, changelogs, long forms) put the middle in a DialogBody. The dialog then caps itself to the viewport and only the body scrolls, its edges fading into the dialog while the header and footer stay pinned. It runs edge to edge, so the fade and the scrollbar use the full width. When it holds only text, give it tabIndex={0} so a keyboard user can scroll it.
Mobile#
Below the sm breakpoint a dialog docks to the bottom edge as a sheet: within thumb reach, with square bottom corners, rising on the drawer curve and keeping the home indicator’s safe area clear. This frame opens at phone width; switch it to Tablet or Desktop to watch the same dialog float back to the center. Pass mobile="center" to keep a dialog floating at every width. There is no grab handle, since a handle promises a drag: reach for Drawer when you want swipe to dismiss.
Nested dialogs#
Open a dialog from inside another and the one underneath steps back: it scales to 90% and lifts slightly, and a lighter second scrim keeps the page from going black. The component tracks the open dialogs itself, so there is nothing to wire, and closing the top dialog brings the other forward in step with its exit. It tracks two levels, which is also the most a flow should ask for.
Share#
A leading DialogIcon above composed Input parts: a read-only link with a copy button, and an email field paired with a send action.
Feedback#
A rating prompt: a single-select ToggleGroup for the score and a Textarea with a live character count. The primary action stays disabled until a score is picked, and the split footer carries a help link on the left.
Two-factor (2FA)#
A verification flow: a live QR Code on a nested surface (so it takes a concentric radius one step inside the dialog) above an OTP Input. Verify enables only once all six digits are in.
Selectable cards#
A single choice rendered as cards rather than bare dots: wrap each option in a <label> around a RadioGroup item and let CSS :has([data-state=checked]) light the selected card. The whole card is the hit target.
Announcement#
A leading DialogIcon, a hero media band, and a split footer: a helper on the left with actions on the right via className="sm:justify-between" (the footer's top divider is on by default).
Multi-step (wizard)#
For create flows that span several screens, put a vertical Stepper in a left rail and swap the active step's form on the right. The Stepper owns no state: drive its value from your own step state. Wrap the body in a Stagger keyed by step so each screen cascades in, and give it a min-h so the dialog holds its height between steps.
Upgrade / Subscribe#
An in-app upsell: a two-column dialog pairing edge-to-edge cover art with a benefits list and a single brand CTA. It composes the same dialogVariants recipe, cancelling the content padding and gap (p-0 gap-0) and splitting into two columns; use size="xl". Drag the frame narrow to watch the columns stack.
Sizes#
size on DialogContent caps the max width; the dialog still shrinks to fit narrow viewports, so it is a ceiling, not a fixed width. The default md (30rem) is the width nearly every dialog should use; reach for the others only when one genuinely needs less or more:
sm:max-w-sm(384px). Confirmations and short prompts.md:max-w-[30rem](480px). The default; most forms and messages.lg:max-w-2xl(672px). Denser forms with side-by-side fields.xl:max-w-4xl(896px). Rich content: tables, multi-column layouts.media: a 16:9 frame as wide as the viewport allows while it still fits the height. A video lightbox: no padding, no header or footer, the embedded player fills it, and the close button turns light over the footage. Pair it withmobile="center"(a video is not a sheet) and give it a visually hiddenDialogTitle.
The previews below are the real DialogContent rendered inline so you can compare the widths directly:
Unsaved changes#
Behavior that needs a real trigger, so this one stays interactive. Use a controlled open / onOpenChange pair and intercept onInteractOutside + onEscapeKeyDown to guard against accidental data loss. When the form is dirty, every close attempt (the ✕, the scrim, Escape, or Cancel) opens a nested confirmation dialog instead.
Accessibility#
Radix traps focus inside the dialog, closes it on Escape and hides the page from screen readers while it is open. On close, focus goes back to the DialogTrigger. A dialog opened by state has no trigger (a keyboard shortcut, a row action, a command), so DialogContent remembers the element that had focus when it opened and returns focus there instead, as long as that element is still on the page. Without this, focus would fall back to the top of the document. Drawer, CommandDialog, Lightbox and FilePreviewDialog do the same.
A dialog opened from a menu item goes back to the button that opened the menu, since the item closes with the menu. From a submenu, that is the button of the top menu. A ContextMenu has no button, so focus goes back to whatever had it before the menu opened.
To send focus somewhere else, pass onCloseAutoFocus, call event.preventDefault() and focus your own element. The dialog then leaves focus alone.
API reference#
Dialog
The root, Radix Dialog.Root; renders no DOM. Every part that renders an element accepts className, merged last.
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | - | Controlled open state. |
| defaultOpen | boolean | false | Initial open state when uncontrolled. |
| onOpenChange | (open: boolean) => void | - | Called when the dialog opens or closes, by the trigger, a close, Escape or the scrim. |
| modal | boolean | true | Trap focus, lock scroll and hide the rest of the page from screen readers while open. |
DialogTrigger
Opens the dialog, Radix Dialog.Trigger. Renders a <button> and forwards all its props.
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | false | Merge onto your own element (a Button) instead of rendering a <button>. |
| Data attribute | Description |
|---|---|
| data-state | "open" or "closed". |
DialogContent
The modal, Radix Dialog.Content portalled to the body with its own scrim and a centering wrapper; forwards all its props. Re-provides its density to every part inside.
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "xl" | "media" | "md" | Max width: 384px, 30rem, 672px, 896px, or a 16:9 video frame with no padding. A ceiling, not a fixed width. |
| density | "comfortable" | "compact" | "compact" | Padding and gaps: 20px or 16px. Falls back to the nearest DensityProvider. |
| mobile | "sheet" | "center" | "sheet" | Below sm, dock to the bottom edge as a sheet, or keep floating mid-screen. |
| showClose | boolean | true | Render the built-in top-right close button. |
| closeLabel | string | "Close" | Accessible label of the built-in close button. |
| onOpenAutoFocus | (event: Event) => void | - | Called as focus moves in on open; preventDefault() and focus your first field instead. |
| onCloseAutoFocus | (event: Event) => void | - | Called as focus returns to the trigger on close; preventDefault() to send it elsewhere. |
| onEscapeKeyDown | (event: KeyboardEvent) => void | - | Called on Escape; preventDefault() keeps the dialog open. |
| onInteractOutside | (event: Event) => void | - | Called on a pointer down or focus outside the content; preventDefault() keeps the dialog open. |
| forceMount | true | - | Keep the content mounted while closed, for an animation library to control. |
| Data attribute | Description |
|---|---|
| data-state | "open" or "closed", on the content and on the scrim. |
| data-stacked | On the scrim, while another dialog is open beneath it: the lighter second wash. |
| CSS variable | Description |
|---|---|
| --surface | Set to var(--popover), read by nested Inputs and Selects. |
| --dialog-nested | Set on the wrapper to 1 or 2 while dialogs are open on top, which scales this one back and nudges it up. |
DialogIcon
An optional muted leading icon above the title, sized by density (24px or 20px). Forwards all native <div> props.
DialogHeader
A left-aligned column holding the title and description. Forwards all native <div> props.
DialogTitle
The heading the dialog is announced by, Radix Dialog.Title (an <h2>). Required for accessibility; hide it with sr-only when the design has none.
DialogDescription
Muted supporting copy, Radix Dialog.Description (a <p>), announced after the title.
DialogStepper
A full-bleed Stepper for wizard dialogs, placed right after the header; it inherits the dialog's density and takes every Stepper prop (value, defaultValue, onValueChange, variant).
| Prop | Type | Default | Description |
|---|---|---|---|
| bordered | boolean | true | Full-bleed divider beneath the stepper. |
| orientation | "horizontal" | "vertical" | "horizontal" | Layout of the steps. |
| density | "comfortable" | "compact" | "compact" | Falls back to the dialog's density. |
DialogBody
The scrolling middle of a long dialog: with it present the content caps to the viewport and only this scrolls, edges fading. Give it tabIndex={0} when it holds only text. Forwards all native <div> props.
DialogFooter
The action row: stacked and reversed on mobile, right-aligned from sm. Forwards all native <div> props.
| Prop | Type | Default | Description |
|---|---|---|---|
| bordered | boolean | true | Full-bleed top divider with a 12px band flush to the dialog's bottom edge; false lets the footer sit below the body. |
DialogClose
Closes the dialog, Radix Dialog.Close. Renders a <button> and forwards all its props.
| Prop | Type | Default | Description |
|---|---|---|---|
| asChild | boolean | false | Merge onto your own element (a footer Button) instead of rendering a <button>. |
