Modal Composition

Focus trapping, scroll locking, dismissal, and the aria-labelledby wiring come from Base UI. This component adds the brand: token-driven surface, sizes, placement, and the enter/exit transition.

Usage

tsx
const [open, setOpen] = useState(false);
<Modal  open={open}  onOpenChange={setOpen}  title="Delete workspace"  description="This removes every project inside it."  footer={    <>      <Button variant="ghost" tone="neutral" onClick={() => setOpen(false)}>Cancel</Button>      <Button tone="danger" onClick={confirm}>Delete</Button>    </>  }/>

Placement

center scales in from the middle — the default, for anything that interrupts. end slides up from the bottom-right corner, for confirmations that should not take the whole screen.

Props

PropTypeDefaultNotes
open / defaultOpenbooleanControlled or uncontrolled. Pass onOpenChange with open.
onOpenChange(open: boolean) => voidFires for every dismissal path: backdrop, Escape, close button.
titleReactNodeRequired — it is what names the dialog for assistive technology.
descriptionReactNodeWired as the accessible description.
footerReactNodeActions, aligned to the end. Put the confirming action last.
size'sm' | 'md' | 'lg''md'Max width step.
placement'center' | 'end''center'Where it sits and how it enters.
triggerReactNodeThe element that opens it. Omit for a fully controlled dialog.

Keyboard

KeyBehaviour
EscapeCloses the dialog
Tab / Shift+TabCycles focus inside it — focus cannot leave while open
Focus on closeReturns to whatever opened it

Do

Give every dialog a title, even a short one — it is the accessible name, not decoration.

Don't

Nest a second dialog inside one to ask a follow-up question; use one dialog whose content changes.

Motion

Enter and exit are CSS transitions driven by Base UI's data-starting-style and data-ending-style, because Base UI owns this element's mount lifecycle. Under reduced motion the dialog still fades — it just stops moving.

Long content scrolls inside the body while the title and footer remain visible. The popup never exceeds the viewport, including on small mobile screens.