Dialog
Modal dialog built from scratch — focus trap, escape-to-close, scroll lock, backdrop blur, scale-in (centred) or slide-down (top) animation. Five width presets, two positions, structured Header + Footer slots, and explicit controls for overlay-click and escape dismissal.
Anatomy
Dialogs are composed from a small set of subcomponents:
DialogRoot— top-level provider. Owns open state.DialogTrigger— the button that opens the dialog. PassasChildto clone props onto your own button.DialogContent— the portaled surface. Carries the size / position props.DialogHeader,DialogTitle,DialogDescription— the structured header block. Title + description get the rightaria-labelledby/aria-describedbywiring automatically.DialogFooter— right-aligned action row with a top divider.DialogClose— dismiss button (useasChildto wrap a real button).
import {
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogRoot,
DialogTitle,
DialogTrigger,
} from '@bwo-ui/react';
<DialogRoot>
<DialogTrigger asChild>
<Button>Move to trash</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Move 3 items to trash</DialogTitle>
<DialogDescription>
They'll stay in trash for 30 days before being permanently deleted.
</DialogDescription>
</DialogHeader>
<p>What's included:</p>
<ul>{/* file list */}</ul>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost">Cancel</Button>
</DialogClose>
<DialogClose asChild>
<Button variant="solid">Move to trash</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</DialogRoot>Sizes
Five width presets cover the common cases. md (480 px) is the default — sized for confirmations and short forms. lg (640 px) and xl (800 px) for forms with more fields or stacked sections. full (92 vw, capped at 1100 px) for image previews and dashboards.
<DialogContent size="sm">…</DialogContent> {/* 360 px */}
<DialogContent size="md">…</DialogContent> {/* 480 px (default) */}
<DialogContent size="lg">…</DialogContent> {/* 640 px */}
<DialogContent size="xl">…</DialogContent> {/* 800 px */}
<DialogContent size="full">…</DialogContent> {/* 92 vw */}Position
position="center" (default) vertically centres the dialog with a scale-in animation — the right choice for short confirmations. position="top" pins 12 vh from the top with a slide-down animation — better for taller content (forms with multiple fields, command palettes, long lists) where centring would push content out of view.
<DialogContent>…</DialogContent> {/* center, default */}
<DialogContent position="top" size="lg">…</DialogContent>Form modal
A common pattern: a lg top-positioned dialog with a form inside. The focus trap keeps tab navigation within the dialog; the submit handler closes the dialog on success (or swaps content to a success state, as in this demo).
<DialogRoot onOpenChange={(open) => { if (open) setSubmitted(false); }}>
<DialogTrigger asChild>
<Button>Invite a teammate</Button>
</DialogTrigger>
<DialogContent size="lg" position="top">
<DialogHeader>
<DialogTitle>Invite a teammate</DialogTitle>
<DialogDescription>They'll get an email with a link to join.</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit}>
<input type="email" className="bwo-input" required />
<SelectRoot value={role} onValueChange={setRole}>
<SelectTrigger>
<SelectValue placeholder="Pick a role…" />
</SelectTrigger>
<SelectContent>
<SelectItem value="viewer">Viewer</SelectItem>
<SelectItem value="editor">Editor</SelectItem>
<SelectItem value="admin">Admin</SelectItem>
</SelectContent>
</SelectRoot>
<DialogFooter>
<DialogClose asChild>
<Button variant="ghost" type="button">Cancel</Button>
</DialogClose>
<Button type="submit">Send invite</Button>
</DialogFooter>
</form>
</DialogContent>
</DialogRoot>Image preview
For media-only modals (image lightboxes, video players, embedded canvases), combine size="full" with unpadded. The content fills the dialog edge-to-edge and the close button switches to a dark, overlay-friendly treatment.
<DialogContent size="full" unpadded>
<img src="/hero.jpg" alt="Hero composition"
style={{ width: '100%', display: 'block', borderRadius: 'var(--bwo-radius-md)' }} />
</DialogContent>Controlled
Pass open + onOpenChange to own the state externally — useful when the dialog is triggered by something other than its own trigger (a deep link, a keyboard shortcut, a parent component's effect).
const [open, setOpen] = useState(false);
<DialogRoot open={open} onOpenChange={setOpen}>
<DialogContent>…</DialogContent>
</DialogRoot>
{/* Open from anywhere */}
<Button onClick={() => setOpen(true)}>Open via state</Button>Escape hatches
By default a dialog dismisses on overlay click and Escape. For destructive or in-progress actions, lock both off so the user has to click an explicit button.
{/* Standard dismiss (default) */}
<DialogContent>…</DialogContent>
{/* Confirm-only — overlay + Escape disabled */}
<DialogContent closeOnOverlayClick={false} closeOnEscape={false}>
<DialogHeader>
<DialogTitle>Delete workspace</DialogTitle>
<DialogDescription>This cannot be undone.</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild><Button variant="ghost">Cancel</Button></DialogClose>
<DialogClose asChild><Button variant="solid">Delete</Button></DialogClose>
</DialogFooter>
</DialogContent>Accessibility
- Focus trap — focus moves into the dialog when it opens; Tab cycles within the dialog only; focus returns to the trigger on close.
- Scroll lock — the background is non-scrollable while a modal dialog is open. Disabled when
modal={false}. DialogTitlewires uparia-labelledbyautomatically.DialogDescriptionwires uparia-describedby. If you omit the title, passaria-labeldirectly onDialogContentso screen readers have a name.- Avoid the
closeOnEscape={false}+ no explicit cancel button combination — that traps users with no keyboard exit. - Don't nest dialogs. Use a sequenced flow (close one, open the next via
onOpenChange) instead. - For non-modal popups (no scroll lock, no focus trap), pass
modal={false}onDialogRoot. Consider whetherPopoveris a better fit — it doesn't portal.
DialogContent props
| Prop | Type | Default | Description |
|---|---|---|---|
size | 'sm' | 'md' | 'lg' | 'xl' | 'full' | 'md' | 360 / 480 / 640 / 800 px or 92 vw for `full` (capped at 1100 px). |
position | 'center' | 'top' | 'center' | 'center' vertically centres with a scale-in animation. 'top' pins 12 vh from the top with slide-down. |
unpadded | boolean | — | Remove the inner padding — for image previews / video where the body manages its own layout. |
closeOnOverlayClick | boolean | true | Whether clicking the overlay dismisses the dialog. |
closeOnEscape | boolean | true | Whether pressing Escape dismisses the dialog. |
hideOverlay | boolean | — | Skip the built-in backdrop. Use for fully custom overlays (e.g. solid colour + custom blur). |
hideClose | boolean | — | Skip the built-in × button — useful when your footer has explicit dismiss buttons only. |
aria-label | string | — | Fallback accessible name when you don't render a `DialogTitle`. |
DialogRoot props
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | — | Controlled open state. Pair with `onOpenChange`. |
defaultOpen | boolean | false | Uncontrolled initial state. |
onOpenChange | (open: boolean) => void | — | Fired whenever the dialog opens or closes (controlled and uncontrolled). |
modal | boolean | true | When `false`, omit the focus trap and scroll lock. The dialog still portals — but users can interact with the page behind it. |
Subcomponents
| Prop | Type | Default | Description |
|---|---|---|---|
DialogTrigger | <button> | — | Opens the dialog. Use `asChild` to clone props onto your own component (typically a `Button`). |
DialogHeader | <div> | — | Wrapper for `DialogTitle` + `DialogDescription`. Adds a 18 px bottom margin. |
DialogTitle | <h2> | — | Wires up `aria-labelledby` on the content automatically. |
DialogDescription | <p> | — | Wires up `aria-describedby` on the content automatically. |
DialogFooter | <div> | — | Right-aligned flex row for action buttons. Adds a 1 px top divider and 22 px top margin. |
DialogClose | <button> | — | Dismisses the dialog when clicked. Use `asChild` to wrap a real Button. Note: the built-in × button in the top-right also dismisses. |
DialogOverlay | <div> | — | Optional — the overlay is rendered automatically by `DialogContent`. Use this only if you've passed `hideOverlay` and want full control. |