Dialog
A modal surface for focused tasks, with a title, body and a Well Gray footer band.
Friday payment run
212 invoices, $1,284,310.42
import { Button } from "@oration/canon/components/button";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@oration/canon/components/dialog";import { Field, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import { CalendarClockIcon } from "lucide-react";import * as React from "react";export function Hero() { const [open, setOpen] = React.useState(false); const [date, setDate] = React.useState("Friday, Oct 2 at 2:00 PM CT"); const id = React.useId(); return ( <div className="flex w-full max-w-md items-center justify-between gap-4 rounded-xl bg-card p-4 text-left shadow-border"> <div className="flex min-w-0 flex-col gap-0.5"> <p className="text-sm font-medium text-foreground"> Friday payment run </p> <p className="text-13 text-muted-foreground tabular-nums"> 212 invoices, $1,284,310.42 </p> </div> <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={<Button type="button" variant="outline" />} > <CalendarClockIcon data-icon="inline-start" aria-hidden="true" /> Schedule </DialogTrigger> <DialogContent> <form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); setOpen(false); toast.add({ type: "success", title: "Payment run scheduled", description: `212 invoices leave ${date}.`, }); }} > <DialogHeader> <DialogTitle>Schedule payment run</DialogTitle> <DialogDescription> 212 invoices to 48 suppliers go out in the first ACH window after this time. </DialogDescription> </DialogHeader> <Field> <FieldLabel htmlFor={id}>Send on</FieldLabel> <Input id={id} value={date} onChange={(event) => setDate(event.target.value) } /> </Field> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 rounded-[10px] bg-muted/70 px-3 py-2 text-13"> <dt className="text-muted-foreground">Total</dt> <dd className="text-right font-medium tabular-nums"> $1,284,310.42 </dd> <dt className="text-muted-foreground"> Approved by </dt> <dd className="text-right">Priya Raman</dd> </dl> <DialogFooter> <DialogClose render={ <Button type="button" variant="outline" /> } > Cancel </DialogClose> <Button type="submit">Schedule run</Button> </DialogFooter> </form> </DialogContent> </Dialog> </div> );}Usage#
Dialog is the modal surface for a focused task the page has to wait for: edit payment terms, schedule a run, review what's about to go out. It is built from parts (a header, a body you compose, and a Well Gray footer band over a hairline) so it fits anything from one field to a scrolling review. Confirmations, short record forms and multi-page flows have ready-made versions built on it; reach for those first. The common mistake is using a dialog for work that needs the page behind it: record details and long edits belong in a sheet.
When to use
- For a short, self-contained task with its own Save: Edit payment terms, Schedule payment run, Edit remit-to email.
- To review something before it happens, such as the invoices in a payment run, with the footer as the commit point.
- For a read-only detail that doesn't deserve a page, such as a remittance summary, closed with Close.
- When you need a layout the ready-made dialogs can't express: a split footer, a scrolling body, a stepper in the header.
When not to use
- To confirm a delete or another consequential action. Use Confirm dialog
- For a create or edit form with a submit and a cancel and nothing else. Use Form dialog
- For record details or a long edit that should keep the list in view. Use Sheet
- For a few controls tied to one button that apply as you change them. Use Popover
- For three or more pages read in order, such as what's new. Use Stacked dialog
- For a success message after an action. Confirm with a toast instead. Use Toast
The One Filled Button Rule
The Hairline-and-Lift Rule
Anatomy#
Edit payment terms
Applies to new invoices from Northwind Freight.
- Scrim.
DialogOverlay, a black scrim at 25% (55% in dark) that fades in over 200ms. Clicking it closes the dialog. - Surface.
DialogContent: Popover White, 12px corners, 16px padding and 16px gaps, the dialog shadow and a 1px ring. 24rem wide from 640px, and the viewport minus 2rem below. - Close button. A 28px ghost icon button 8px from the top right, named Close for screen readers. Hide it with
showCloseButton={false}when the footer already closes. - Header.
DialogHeaderstacksDialogTitle(Title Large: 16px, weight 500) andDialogDescription(14px Slate Meta) 8px apart. They label and describe the dialog. - Body. Whatever the task needs: fields, a tint well of totals, a list. Compose it directly inside the content; there is no body part.
- Footer band.
DialogFooterbleeds to the edges with-mx-4 -mb-4, fills Well Gray at 50% over a hairline and right-aligns its buttons from 640px. Below that it stacks them with the primary on top.
Examples#
Header, body and footer
Put the form directly inside DialogContent with the footer inside it, so Enter submits and the band still bleeds to the edges. Cancel is a DialogClose; the submit closes by setting open.
import { Button } from "@oration/canon/components/button";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@oration/canon/components/dialog";import { Field, FieldDescription, FieldLabel } from "@oration/canon/components/field";import { OptionSelect } from "@oration/canon/components/option-select";import { Switch } from "@oration/canon/components/switch";import { toast } from "@oration/canon/components/toast";import { PencilIcon } from "lucide-react";import * as React from "react";export function Structure() { const [open, setOpen] = React.useState(false); const [terms, setTerms] = React.useState("Net 30"); const [discount, setDiscount] = React.useState(true); const termsId = React.useId(); const discountId = React.useId(); return ( <Dialog open={open} onOpenChange={setOpen}> <DialogTrigger render={<Button type="button" variant="outline" />}> <PencilIcon data-icon="inline-start" aria-hidden="true" /> Edit payment terms </DialogTrigger> <DialogContent className="sm:max-w-md"> <form className="grid gap-4" onSubmit={(event) => { event.preventDefault(); setOpen(false); toast.add({ type: "success", title: "Payment terms saved", description: `Northwind Freight is on ${terms} from the next invoice.`, }); }} > <DialogHeader> <DialogTitle>Edit payment terms</DialogTitle> <DialogDescription> Applies to new invoices from Northwind Freight. Open invoices keep their current due dates. </DialogDescription> </DialogHeader> <Field> <FieldLabel htmlFor={termsId}>Terms</FieldLabel> <OptionSelect id={termsId} label="Terms" value={terms} onValueChange={setTerms} options={[ "Net 15", "Net 30", "Net 45", "Net 60", "2/10 net 30", ]} /> </Field> <Field orientation="horizontal" className="items-start justify-between gap-4 rounded-[10px] bg-muted/70 p-3" > <div className="flex flex-col gap-0.5"> <FieldLabel htmlFor={discountId}> Take early-pay discounts </FieldLabel> <FieldDescription className="text-[13px]"> Pays inside the discount window when cash allows. </FieldDescription> </div> <Switch id={discountId} checked={discount} onCheckedChange={setDiscount} /> </Field> <DialogFooter> <DialogClose render={<Button type="button" variant="outline" />} > Cancel </DialogClose> <Button type="submit">Save terms</Button> </DialogFooter> </form> </DialogContent> </Dialog> );}Widths
24rem by default. The product uses 28rem (sm:max-w-md) most, 32rem for a message or a two-column form, and 42rem for comparisons and tables. Below 640px every width is the viewport minus 2rem.
import { Button } from "@oration/canon/components/button";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@oration/canon/components/dialog";import { Field } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { Textarea } from "@oration/canon/components/textarea";import { toast } from "@oration/canon/components/toast";export function Widths() { const widths = [ { label: "Default, 24rem", className: undefined, title: "Rename view", description: "Everyone who can see Due this week sees the new name.", body: <Input aria-label="View name" defaultValue="Due this week" />, action: "Rename", }, { label: "28rem", className: "sm:max-w-md", title: "Invite an approver", description: "Approvers can release payment runs up to their limit.", body: ( <Input aria-label="Email" type="email" defaultValue="aisha.bello@cedarline.io" /> ), action: "Send invite", }, { label: "32rem", className: "sm:max-w-lg", title: "Edit remittance email", description: "Sent to each supplier when a payment leaves.", body: ( <Textarea aria-label="Message" defaultValue="Hi {{supplier_name}}, payment for {{invoice_count}} invoices is on its way and should arrive by {{arrival_date}}." className="min-h-24" /> ), action: "Save email", }, { label: "42rem", className: "sm:max-w-2xl", title: "Compare W-9 versions", description: "Halcyon uploaded a new W-9 on Sep 24.", body: ( <dl className="grid grid-cols-[8rem_1fr_1fr] gap-x-4 gap-y-2 text-13"> <dt className="text-muted-foreground">Field</dt> <dd className="text-muted-foreground">Mar 3, 2025</dd> <dd className="text-muted-foreground">Sep 24, 2026</dd> <dt className="text-muted-foreground">Legal name</dt> <dd>Halcyon Logistics LLC</dd> <dd>Halcyon Logistics LLC</dd> <dt className="text-muted-foreground">Address</dt> <dd>118 Pier Road, Oakland</dd> <dd>4400 Harbor Way, Alameda</dd> </dl> ), action: "Accept new W-9", }, ]; return ( <> {widths.map((width) => ( <Dialog key={width.label}> <DialogTrigger render={ <Button type="button" variant="outline" size="sm" /> } > {width.label} </DialogTrigger> <DialogContent className={width.className}> <DialogHeader> <DialogTitle>{width.title}</DialogTitle> <DialogDescription> {width.description} </DialogDescription> </DialogHeader> {width.body} <DialogFooter> <DialogClose render={ <Button type="button" variant="outline" /> } > Cancel </DialogClose> <DialogClose render={ <Button type="button" onClick={() => toast.add({ title: width.action }) } /> } > {width.action} </DialogClose> </DialogFooter> </DialogContent> </Dialog> ))} </> );}Scrollable body
For a long list, cap the height, make the content a flex column with p-0 gap-0, let the body scroll and reset the footer's bleed with m-0. The header and footer stay put.
import { Button } from "@oration/canon/components/button";import { Dialog, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@oration/canon/components/dialog";import { toast } from "@oration/canon/components/toast";export function Scrolling() { const invoices = [ ["INV-20931", "Northwind Freight", "$18,240.00"], ["INV-20932", "Halcyon", "$9,612.50"], ["INV-20935", "Orchard Street", "$4,120.00"], ["INV-20938", "Bayline Packaging", "$22,905.18"], ["INV-20940", "Northwind Freight", "$7,480.00"], ["INV-20944", "Kestrel Office Supply", "$1,264.33"], ["INV-20947", "Halcyon", "$12,050.00"], ["INV-20951", "Summit Janitorial", "$3,300.00"], ["INV-20953", "Orchard Street", "$6,875.40"], ["INV-20956", "Pioneer Metals", "$41,200.00"], ["INV-20958", "Bayline Packaging", "$5,618.75"], ["INV-20961", "Northwind Freight", "$9,990.00"], ["INV-20963", "Greenway Fleet", "$2,450.00"], ["INV-20966", "Kestrel Office Supply", "$884.10"], ]; return ( <Dialog> <DialogTrigger render={<Button type="button" variant="outline" />}> Review 14 invoices </DialogTrigger> <DialogContent className="flex max-h-[min(34rem,calc(100dvh-2rem))] flex-col gap-0 overflow-hidden p-0 sm:max-w-lg"> <DialogHeader className="border-b border-border p-4 pr-12"> <DialogTitle>Review payment run</DialogTitle> <DialogDescription> 14 invoices due by Friday, Oct 2, approved by Priya Raman. </DialogDescription> </DialogHeader> <ul className="min-h-0 flex-1 overflow-y-auto overscroll-contain px-2 py-1"> {invoices.map(([id, supplier, amount]) => ( <li key={id} className="flex h-9 items-center gap-3 border-b border-border px-2 text-13 last:border-b-0" > <span className="w-24 shrink-0 font-mono text-xs text-muted-foreground"> {id} </span> <span className="min-w-0 flex-1 truncate"> {supplier} </span> <span className="tabular-nums">{amount}</span> </li> ))} </ul> <DialogFooter className="m-0"> <DialogClose render={<Button type="button" variant="outline" />} > Cancel </DialogClose> <DialogClose render={ <Button type="button" onClick={() => toast.add({ type: "success", title: "Payment run approved", description: "14 invoices leave Friday, Oct 2.", }) } /> } > Approve run </DialogClose> </DialogFooter> </DialogContent> </Dialog> );}Read-only with a Close footer
When nothing can change, drop the corner button with showCloseButton={false} and let DialogFooter showCloseButton add a single outline Close.
import { Button } from "@oration/canon/components/button";import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger,} from "@oration/canon/components/dialog";export function ReadOnly() { return ( <Dialog> <DialogTrigger render={<Button type="button" variant="outline" />}> View remittance </DialogTrigger> <DialogContent showCloseButton={false}> <DialogHeader> <DialogTitle>Remittance RMT-4471</DialogTitle> <DialogDescription> Sent to ap@northwindfreight.com on Monday, Sep 28 at 9:12 AM. </DialogDescription> </DialogHeader> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-2 text-13"> <dt className="text-muted-foreground">Invoices</dt> <dd className="text-right tabular-nums">3</dd> <dt className="text-muted-foreground">Amount</dt> <dd className="text-right tabular-nums">$35,710.00</dd> <dt className="text-muted-foreground">Method</dt> <dd className="text-right">ACH</dd> </dl> <DialogFooter showCloseButton /> </DialogContent> </Dialog> );}States#
| State | Treatment |
|---|---|
| Closed | Nothing is rendered. The trigger shows aria-expanded="false" when it is a DialogTrigger. |
| Opening | The surface fades in from a 0.96 scale over 200ms on the house ease-out while the scrim fades in over 200ms. |
| Open | Focus is inside and trapped, the page behind is inert and its scroll is locked. The trigger carries data-popup-open. |
| Nested | A dialog opened from inside another gets data-nested, and the parent gets data-nested-dialog-open. The nested scrim doesn't draw, and the parent keeps its size and position, so the two surfaces overlap; prefer replacing the first dialog over stacking a second. |
| Closing | Fades out toward a 0.98 scale in 140ms, faster than it opened, then unmounts. |
| Reduced motion | The scale is zeroed by the global reduced-motion rule; only the fades remain. |
Behavior#
- Built on Base UI Dialog.
DialogTriggeropens it; Esc, a click on the scrim, the close button or anyDialogClosecloses it.disablePointerDismissalkeeps the scrim from closing it. - Opening moves focus to the first focusable element inside, and closing returns it to whatever was focused before, usually the trigger. Change either with
initialFocusandfinalFocusonDialogContent. - Uncontrolled by default. Control it with
openandonOpenChangewhen a form submit, a menu item or a row action opens or closes it; the product's dialogs are almost all controlled. - Put a
<form>directly insideDialogContentwithclassName="grid gap-4"and the footer inside the form, so Enter submits and the band still reaches the edges. - Width is a class on the content: the default is 24rem, and the product uses
sm:max-w-md(28rem) most, thensm:max-w-lg,sm:max-w-xlandsm:max-w-2xlfor tables and comparisons. - For a long body, make the content a flex column with
p-0 gap-0 overflow-hiddenand amax-h, pad the header, give the bodymin-h-0 flex-1 overflow-y-auto overscroll-contain, and reset the footer's bleed withm-0. - Timings come from globals.css, keyed on
data-slot="dialog-content": 200ms in from 0.96 and 140ms out toward 0.98. Adddata-no-motionto the content to open it with no animation, as the command menu does.
Choosing an overlay#
Seven components take over the screen. Pick by the question the overlay asks: a yes or no goes to a confirmation, a few fields to a form dialog, details that belong beside a list to a sheet. For a few controls tied to one button, use a popover instead.
| Component | Reach for it when | Shape | Dismissed by |
|---|---|---|---|
| Dialog (this page) | A focused task the page waits for: a short form, a review, a setting with its own Save. You compose the header, body and footer. | Centered, 24rem by default, up to 48rem | Esc, a click on the scrim, the close button |
| Confirm dialog | Confirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large. | Centered, 24rem, 28rem when typed | Esc or an answer. The scrim doesn't close it |
| Alert dialog | A decision Confirm dialog can't express, such as three answers or a media tile. You build the parts; Confirm dialog is built on it. | Centered, 20 or 24rem | Esc or an answer. The scrim doesn't close it |
| Form dialog | Creating or editing one record with two to six fields, a submit and a cancel. | Centered, 28rem | Esc, the scrim, Cancel, the close button |
| Stacked dialog | Three to six pages read or completed in order: what's new, connecting a provider. | Centered, 28rem, with a control bar | Esc, the scrim, Done, the close button |
| Sheet | Record details, logs or a longer edit that should keep the list it came from in view. | From an edge, 24rem wide by default | Esc, the scrim, the close button |
| Drawer | Touch-first flows on phones that people expect to swipe away. Not used in the product yet. | From the bottom, up to the viewport minus 6rem | A swipe, Esc, the scrim |
Do and don't#
Edit payment terms
Applies to new invoices from Northwind Freight.
Edit payment terms
Applies to new invoices from Northwind Freight.
Approve payment run?
212 invoices to 48 suppliers leave Friday, Oct 2.
Are you sure?
This action will proceed.
Content#
- Titles are sentence case with no trailing period: a verb and object for tasks (Edit payment terms), a question for decisions (Approve payment run?).
- The description says what the change applies to or what happens next in one sentence: Applies to new invoices from Northwind Freight.
- The primary button repeats the title's verb: Save terms, Schedule run, Send invite. Use Cancel to back out of a change and Close when nothing changed.
- Name amounts, counts and dates in the body, with tabular figures, so the decision is made on real numbers.
- Confirm the result with a toast after the dialog closes, not with a second dialog.
Accessibility#
- The trigger gets
aria-haspopup="dialog"andaria-expanded. The popup isrole="dialog", labelled byDialogTitleand described byDialogDescription. Every dialog needs a title; hide it withsr-onlyif the design has none. - Focus moves in on open, is trapped while open and returns to the trigger on close. When opened from a menu item, it returns to the menu's trigger.
- The page behind is inert, so screen readers can't wander out of the dialog.
- The close button's name is Close. It is icon-only, so it counts toward the 24px hit area rule at 28px.
DialogTitlerenders anh2. Inside a page whose outline already usesh2, that is still correct because the dialog is its own context.- Under reduced motion the scale is removed and only opacity animates.
| Keys | Action |
|---|---|
| Enter | Opens the dialog from its trigger, and submits the form inside. |
| Tab | Moves through the controls inside, wrapping at the ends. |
| ShiftTab | Moves backwards, wrapping to the last control. |
| Esc | Closes the dialog and returns focus to the trigger. |
Design tokens#
| Token | Used for |
|---|---|
--popover | Surface fill |
--popover-foreground | Text |
shadow-lg | The dialog shadow |
--foreground | The 1px ring at 10% |
--muted | Footer band at 50% |
--border | The hairline over the footer band |
--muted-foreground | DialogDescription |
--radius-xl | 12px corners on the surface and the footer's bottom |
--ease-out | 200ms in and 140ms out, from globals.css |
bg-black/25 | The scrim, bg-black/55 in dark |
API reference#
Dialog
The root. Holds the open state and renders no element.
Other props spread onto Base UI Dialog.Root.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | No default | Controls whether it is open. |
defaultOpen | boolean | false | Whether it starts open when uncontrolled. |
onOpenChange | (open: boolean, eventDetails) => void | No default | Called when it opens or closes. eventDetails.reason says why, such as escape-key or outside-press. |
onOpenChangeComplete | (open: boolean) => void | No default | Called after the open or close animation ends. |
modal | boolean | "trap-focus" | true | true traps focus, locks scroll and blocks outside clicks. "trap-focus" only traps focus. Leave it on. |
disablePointerDismissal | boolean | false | Keeps a click on the scrim from closing it. |
actionsRef | RefObject<{ close: () => void; unmount: () => void }> | No default | Imperative close and unmount. |
DialogTrigger
The button that opens it.
Other props spread onto Base UI Dialog.Trigger (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Render your own button, such as <Button type="button" variant="outline" />. |
nativeButton | boolean | true | Set false when render isn't a <button>. |
DialogContent
The surface, rendered in a portal with the scrim and the close button.
Other props spread onto Base UI Dialog.Popup.
| Prop | Type | Default | Description |
|---|---|---|---|
showCloseButton | boolean | true | Draws the close button in the top right. |
initialFocus | boolean | RefObject<HTMLElement> | (openType) => HTMLElement | boolean | null | void | No default | Where focus goes on open. Defaults to the first focusable element. |
finalFocus | boolean | RefObject<HTMLElement> | (closeType) => HTMLElement | boolean | null | void | No default | Where focus goes on close. Defaults to the trigger. |
className | string | No default | Merged after the defaults. Widths such as sm:max-w-md, and p-0 gap-0 for custom layouts. |
DialogHeader
Stacks the title and description 8px apart.
Other props spread onto <div>.
No props of its own.
DialogTitle
Labels the dialog. Title Large.
Other props spread onto Base UI Dialog.Title (<h2>).
No props of its own.
DialogDescription
Describes the dialog. Slate Meta; links inside are underlined.
Other props spread onto Base UI Dialog.Description (<p>).
No props of its own.
DialogClose
Closes the dialog. Render a button through it, such as Cancel.
Other props spread onto Base UI Dialog.Close (<button>).
| Prop | Type | Default | Description |
|---|---|---|---|
render | ReactElement | (props, state) => ReactElement | No default | Usually <Button type="button" variant="outline" />. |
DialogOverlay
The scrim. Rendered by DialogContent; export it only to build a custom popup.
Other props spread onto Base UI Dialog.Backdrop.
No props of its own.
DialogPortal
Portals its children to the body. Rendered by DialogContent.
Other props spread onto Base UI Dialog.Portal.
| Prop | Type | Default | Description |
|---|---|---|---|
container | HTMLElement | RefObject<HTMLElement> | No default | Where to portal to. Defaults to the body. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
DESIGN.md sets 24rem as the default width, and that is the component's default, but the product passes sm:max-w-md (28rem) in 25 dialogs and the default in only 2. 28rem is the de facto default.
The footer's -mx-4 -mb-4 bleed assumes the 16px padding. Any p-0 layout must reset it with m-0, or the band overflows the surface.
The component's own classes still say duration-100, zoom-in-95 and zoom-out-95. globals.css overrides them through data-slot, so a hand-rolled surface that copies the classes without the slot animates at 100ms from 0.95.
The close button is icon-only with no tooltip, unlike every other icon-only button in the suite.