Dialog
A focused task on top of the page — confirm a cancellation, create a booking, edit a week of hours. A squircle of the floating glass in three widths over a softly blurred page, built on the vendored Base UI dialog: it traps focus, closes on Escape and hands focus back to its trigger. On a phone it's a bottom sheet.
Playground
import { Button } from "@/components/shared/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/shared/dialog";
<Dialog>
<DialogTrigger render={<Button variant="secondary">Open dialog</Button>} />
<DialogContent size="sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Cancel booking?</DialogTitle>
<DialogDescription>
Jana Nováková's skin fade on Friday at 10:00 will be cancelled, and she'll get a text.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="secondary" />}>
Keep booking
</DialogClose>
<Button variant="primary" onClick={cancelBooking}>
Cancel booking
</Button>
</DialogFooter>
</DialogContent>
</Dialog>Confirm
A small dialog with a question, one sentence of consequence and two buttons. The way out comes first and takes focus; the confirming button repeats the title's verb. No X — the footer already offers a way out.
import { Button } from "@/components/shared/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/shared/dialog";
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger render={<Button variant="secondary">Cancel booking</Button>} />
<DialogContent size="sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Cancel booking?</DialogTitle>
<DialogDescription>
Jana Nováková's skin fade on Friday at 10:00 will be cancelled,
and she'll get a text.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose render={<Button variant="secondary" />}>Keep booking</DialogClose>
<Button variant="primary" loading={cancelling} onClick={confirm}>
{cancelling ? "Cancelling" : "Cancel booking"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>Sizes
sm 24rem for confirmations, md 28rem by default, lg 32rem for forms that need room. On a phone every size fills the width, less a 1rem margin each side.
<DialogContent size="sm">…</DialogContent>
<DialogContent>…</DialogContent>
<DialogContent size="lg">…</DialogContent>With a form
Fields go in a DialogBody, which stacks them 16px apart. The first field takes focus when the dialog opens; the main action sits last in the footer.
import { Button } from "@/components/shared/button";
import { Field, FieldLabel } from "@/components/shared/field";
import { Input } from "@/components/shared/input";
<Dialog>
<DialogTrigger render={<Button leadingIcon={Plus}>New booking</Button>} />
<DialogContent>
<DialogHeader>
<DialogTitle>New booking</DialogTitle>
</DialogHeader>
<DialogBody>
<Field>
<FieldLabel htmlFor="nb-service">Service</FieldLabel>
<Select id="nb-service" items={services}>…</Select>
</Field>
<Field>
<FieldLabel htmlFor="nb-name">Client's name</FieldLabel>
<Input id="nb-name" />
</Field>
…
</DialogBody>
<DialogFooter>
<DialogClose render={<Button variant="secondary" />}>Cancel</DialogClose>
<Button loading={creating} onClick={create}>Create booking</Button>
</DialogFooter>
</DialogContent>
</Dialog>Long content
A dialog never grows past five sixths of the screen. When its content would, only the DialogBody scrolls — the title and the save button stay in view.
<DialogContent size="lg">
<DialogHeader>
<DialogTitle>Opening hours for Martina</DialogTitle>
<DialogDescription>…</DialogDescription>
</DialogHeader>
<DialogBody>
{week.map((day) => <Field key={day.name}>…</Field>)}
</DialogBody>
<DialogFooter>
<Button onClick={save}>Save hours</Button>
</DialogFooter>
</DialogContent>Opened from a row
One dialog serves every row: open it from state instead of a trigger. Keep open and the row separate, so the text doesn't blank out while the dialog animates away.
10:00 · Skin fade
Jana Nováková
11:00 · Beard trim
Peter Kováč
const [open, setOpen] = useState(false);
const [target, setTarget] = useState<Booking | null>(null);
<Button variant="ghost" size="sm" onClick={() => { setTarget(booking); setOpen(true); }}>
Cancel
</Button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent size="sm" showCloseButton={false}>
<DialogHeader>
<DialogTitle>Cancel booking?</DialogTitle>
<DialogDescription>{target && describe(target)}</DialogDescription>
</DialogHeader>
<DialogFooter>…</DialogFooter>
</DialogContent>
</Dialog>On a phone, a sheet
Under sm every dialog rises from the screen's foot as a bottom sheet (Base UI's Drawer): the full width, a handle on top, dragged down or pressed outside to close (no X: Cancel is the way out), the page dimming less as it's pulled away. Nothing changes in how it's built — the same header, body and footer, the title and description the dialog's own — and a field in it stays in view over the keyboard. It moves on the motion tokens (SHEET_MOTION): up on duration-slow, down a step quicker, following the finger while it's dragged. A dialog that is a page of its own on a phone (SectionDialog) passes sheet={false}. Narrow this window, or open the catalog on a phone, to see it.
API reference
DialogContent
| Prop | Type | Default | Description |
|---|---|---|---|
| size | "sm" | "md" | "lg" | "md" | 24, 28 or 32rem wide from the sm breakpoint; full width less 1rem each side below it. |
| showCloseButton | boolean | true | The ghost X in the top corner. Drop it when the footer already offers a way out. |
| closeLabel | string | "Close" | The X's accessible name. Pass it translated. |
| initialFocus | RefObject | boolean | fn | first field | Where focus lands on open — by default the first focusable element. Base UI Popup prop. |
| className | string | — | Layout only. Never restyle the surface here. |
Dialog
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state; pair it with onOpenChange. Leave both out and let DialogTrigger open it. |
| onOpenChange | (open: boolean) => void | — | Called on every open and close — trigger, DialogClose, Escape, a press outside. |
| defaultOpen | boolean | false | Open on first render, uncontrolled. |
| sheet | boolean | true | On a phone, a bottom sheet. false keeps it a dialog everywhere — one that is a page of its own there. |
| disablePointerDismissal | boolean | false | Keep it open on a press outside — for a form that would lose typed work. |
Parts
| Prop | Type | Default | Description |
|---|---|---|---|
| DialogTrigger | render | — | Opens the dialog. Give it a Button: render={<Button>…</Button>}. |
| DialogHeader | div | — | Stacks the title over the description. |
| DialogTitle | h2 | — | Names the dialog for assistive tech. Every dialog has one. |
| DialogDescription | p | — | A quiet sentence under the title. |
| DialogBody | div | — | Stacks content 16px apart and scrolls when the dialog meets the screen's edge. |
| DialogFooter | div | — | The actions, main one last in source: stacked full width on a phone with the main action on top; in a row at the end from sm. |
| DialogClose | render | — | Closes the dialog. Give it a Button: render={<Button variant="secondary" />}. |
Guidelines
- Reach for a dialog only when the task must interrupt: a decision that can't be undone, or a short form that belongs to this moment. Anything that can wait is a toast; anything tied to one control is a popover.
- Title the dialog with the question or the task — “Cancel booking?”, “New booking” — in sentence case. The confirming button repeats its verb; never “OK” or “Yes”.
- One main action, last in the footer. The way out comes first, as a secondary
DialogClose, and is where focus lands in a confirmation. - While the action runs, keep the dialog open and the button
loading; close it on success. On failure keep it open and say what went wrong. - Pass
closeLabeltranslated wherever the X shows — screen readers read it aloud. - Don't stack dialogs. If a step needs another step, it's a page.