Skip to content

Dialog

A modal surface for focused tasks, with a title, body and a Well Gray footer band.

Status
Stable
Category
Overlays
Adoption
Not used yet
import { Dialog } from "@oration/canon/components/dialog";
packages/canon/src/components/dialog.tsx

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

While a dialog is open it is the view, so its footer holds the one filled button: the action the dialog exists for, last in the row. Cancel is outline; a secondary destructive action is the red tint on the left.

The Hairline-and-Lift Rule

The surface takes its edge from the dialog shadow and a 1px ink ring at 10%, never a CSS border. The only CSS border is the hairline structural divider on top of the footer band.

Anatomy#

Edit payment terms

Applies to new invoices from Northwind Freight.

Net 30
  1. Scrim. DialogOverlay, a black scrim at 25% (55% in dark) that fades in over 200ms. Clicking it closes the dialog.
  2. 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.
  3. 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.
  4. Header. DialogHeader stacks DialogTitle (Title Large: 16px, weight 500) and DialogDescription (14px Slate Meta) 8px apart. They label and describe the dialog.
  5. Body. Whatever the task needs: fields, a tint well of totals, a list. Compose it directly inside the content; there is no body part.
  6. Footer band. DialogFooter bleeds 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>    );}

Opened from a menu

A menu item can't be a DialogTrigger, so control the dialog with open and set it from the item. Focus returns to the menu's trigger when it closes.

Northwind Freightap@northwindfreight.com
import { Button } from "@oration/canon/components/button";import {  Dialog,  DialogClose,  DialogContent,  DialogDescription,  DialogFooter,  DialogHeader,  DialogTitle,} from "@oration/canon/components/dialog";import {  DropdownMenu,  DropdownMenuContent,  DropdownMenuItem,  DropdownMenuTrigger,} from "@oration/canon/components/dropdown-menu";import { Field, FieldLabel } from "@oration/canon/components/field";import { Input } from "@oration/canon/components/input";import { toast } from "@oration/canon/components/toast";import { Tooltip, TooltipContent, TooltipTrigger } from "@oration/canon/components/tooltip";import { MoreHorizontalIcon, PencilIcon } from "lucide-react";import * as React from "react";export function FromMenu() {    const [open, setOpen] = React.useState(false);    const [address, setAddress] = React.useState("ap@northwindfreight.com");    const id = React.useId();    return (        <div className="flex w-full max-w-md items-center justify-between rounded-xl bg-card py-2 pr-2 pl-4 text-13 shadow-border">            <div className="flex min-w-0 flex-col">                <span className="font-medium">Northwind Freight</span>                <span className="truncate text-muted-foreground">                    {address}                </span>            </div>            <DropdownMenu>                <Tooltip>                    <TooltipTrigger                        render={                            <DropdownMenuTrigger                                render={                                    <Button                                        type="button"                                        variant="ghost"                                        size="icon-sm"                                        aria-label="Supplier actions"                                    />                                }                            />                        }                    >                        <MoreHorizontalIcon aria-hidden="true" />                    </TooltipTrigger>                    <TooltipContent>Supplier actions</TooltipContent>                </Tooltip>                <DropdownMenuContent align="end" className="w-52">                    <DropdownMenuItem onClick={() => setOpen(true)}>                        <PencilIcon aria-hidden="true" />                        Edit remit-to email                    </DropdownMenuItem>                    <DropdownMenuItem                        onClick={() =>                            toast.add({ title: "Opened Northwind Freight" })                        }                    >                        Open supplier                    </DropdownMenuItem>                </DropdownMenuContent>            </DropdownMenu>            <Dialog open={open} onOpenChange={setOpen}>                <DialogContent>                    <form                        className="grid gap-4"                        onSubmit={(event) => {                            event.preventDefault();                            setOpen(false);                            toast.add({                                type: "success",                                title: "Remit-to email updated",                                description: `Remittances for Northwind Freight go to ${address}.`,                            });                        }}                    >                        <DialogHeader>                            <DialogTitle>Edit remit-to email</DialogTitle>                            <DialogDescription>                                Remittance advice for Northwind Freight goes                                here.                            </DialogDescription>                        </DialogHeader>                        <Field>                            <FieldLabel htmlFor={id}>Email</FieldLabel>                            <Input                                id={id}                                type="email"                                value={address}                                onChange={(event) =>                                    setAddress(event.target.value)                                }                            />                        </Field>                        <DialogFooter>                            <DialogClose                                render={                                    <Button type="button" variant="outline" />                                }                            >                                Cancel                            </DialogClose>                            <Button type="submit">Save email</Button>                        </DialogFooter>                    </form>                </DialogContent>            </Dialog>        </div>    );}

States#

States
StateTreatment
ClosedNothing is rendered. The trigger shows aria-expanded="false" when it is a DialogTrigger.
OpeningThe surface fades in from a 0.96 scale over 200ms on the house ease-out while the scrim fades in over 200ms.
OpenFocus is inside and trapped, the page behind is inert and its scroll is locked. The trigger carries data-popup-open.
NestedA 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.
ClosingFades out toward a 0.98 scale in 140ms, faster than it opened, then unmounts.
Reduced motionThe scale is zeroed by the global reduced-motion rule; only the fades remain.

Behavior#

  • Built on Base UI Dialog. DialogTrigger opens it; Esc, a click on the scrim, the close button or any DialogClose closes it. disablePointerDismissal keeps 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 initialFocus and finalFocus on DialogContent.
  • Uncontrolled by default. Control it with open and onOpenChange when 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 inside DialogContent with className="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, then sm:max-w-lg, sm:max-w-xl and sm:max-w-2xl for tables and comparisons.
  • For a long body, make the content a flex column with p-0 gap-0 overflow-hidden and a max-h, pad the header, give the body min-h-0 flex-1 overflow-y-auto overscroll-contain, and reset the footer's bleed with m-0.
  • Timings come from globals.css, keyed on data-slot="dialog-content": 200ms in from 0.96 and 140ms out toward 0.98. Add data-no-motion to 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.

Which overlay to use
ComponentReach for it whenShapeDismissed 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 48remEsc, a click on the scrim, the close button
Confirm dialogConfirming one consequential action: delete, remove, rotate, move. Add a typed name when the loss is large.Centered, 24rem, 28rem when typedEsc or an answer. The scrim doesn't close it
Alert dialogA 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 24remEsc or an answer. The scrim doesn't close it
Form dialogCreating or editing one record with two to six fields, a submit and a cancel.Centered, 28remEsc, the scrim, Cancel, the close button
Stacked dialogThree to six pages read or completed in order: what's new, connecting a provider.Centered, 28rem, with a control barEsc, the scrim, Done, the close button
SheetRecord details, logs or a longer edit that should keep the list it came from in view.From an edge, 24rem wide by defaultEsc, the scrim, the close button
DrawerTouch-first flows on phones that people expect to swipe away. Not used in the product yet.From the bottom, up to the viewport minus 6remA swipe, Esc, the scrim

Do and don't#

Edit payment terms

Applies to new invoices from Northwind Freight.

Do. Give the footer one filled button for the action the dialog exists for, last in the row, with Cancel beside it in outline.

Edit payment terms

Applies to new invoices from Northwind Freight.

Don't. Fill both buttons. Cancel competes with the commit and people hit the wrong one.

Approve payment run?

212 invoices to 48 suppliers leave Friday, Oct 2.

Do. Title the dialog with the task or the question, and repeat its verb on the button: Approve payment run? confirms with Approve run.

Are you sure?

This action will proceed.

Don't. Ask Are you sure? and answer with OK. Nobody can tell what happens without reading the body.
Do. Keep a dialog to one task that fits on one screen, and move record details or long edits to a sheet.
Don't. Put tabs, a sidebar and a second dialog inside a dialog. It has become a page that can't be linked to.

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" and aria-expanded. The popup is role="dialog", labelled by DialogTitle and described by DialogDescription. Every dialog needs a title; hide it with sr-only if 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.
  • DialogTitle renders an h2. Inside a page whose outline already uses h2, that is still correct because the dialog is its own context.
  • Under reduced motion the scale is removed and only opacity animates.
Keyboard interactions
KeysAction
EnterOpens the dialog from its trigger, and submits the form inside.
TabMoves through the controls inside, wrapping at the ends.
ShiftTabMoves backwards, wrapping to the last control.
EscCloses the dialog and returns focus to the trigger.

Design tokens#

Design tokens
TokenUsed for
--popoverSurface fill
--popover-foregroundText
shadow-lgThe dialog shadow
--foregroundThe 1px ring at 10%
--mutedFooter band at 50%
--borderThe hairline over the footer band
--muted-foregroundDialogDescription
--radius-xl12px corners on the surface and the footer's bottom
--ease-out200ms in and 140ms out, from globals.css
bg-black/25The 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.

Props of Dialog
PropTypeDefaultDescription
openbooleanNo defaultControls whether it is open.
defaultOpenbooleanfalseWhether it starts open when uncontrolled.
onOpenChange(open: boolean, eventDetails) => voidNo defaultCalled when it opens or closes. eventDetails.reason says why, such as escape-key or outside-press.
onOpenChangeComplete(open: boolean) => voidNo defaultCalled after the open or close animation ends.
modalboolean | "trap-focus"truetrue traps focus, locks scroll and blocks outside clicks. "trap-focus" only traps focus. Leave it on.
disablePointerDismissalbooleanfalseKeeps a click on the scrim from closing it.
actionsRefRefObject<{ close: () => void; unmount: () => void }>No defaultImperative close and unmount.

DialogTrigger

The button that opens it.

Other props spread onto Base UI Dialog.Trigger (<button>).

Props of DialogTrigger
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultRender your own button, such as <Button type="button" variant="outline" />.
nativeButtonbooleantrueSet 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.

Props of DialogContent
PropTypeDefaultDescription
showCloseButtonbooleantrueDraws the close button in the top right.
initialFocusboolean | RefObject<HTMLElement> | (openType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on open. Defaults to the first focusable element.
finalFocusboolean | RefObject<HTMLElement> | (closeType) => HTMLElement | boolean | null | voidNo defaultWhere focus goes on close. Defaults to the trigger.
classNamestringNo defaultMerged 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.

DialogFooter

The Well Gray band over a hairline.

Other props spread onto <div>.

Props of DialogFooter
PropTypeDefaultDescription
showCloseButtonbooleanfalseAppends an outline Close button that closes the dialog.

DialogClose

Closes the dialog. Render a button through it, such as Cancel.

Other props spread onto Base UI Dialog.Close (<button>).

Props of DialogClose
PropTypeDefaultDescription
renderReactElement | (props, state) => ReactElementNo defaultUsually <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.

Props of DialogPortal
PropTypeDefaultDescription
containerHTMLElement | RefObject<HTMLElement>No defaultWhere 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.