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
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
| Prop | Type | Default | Notes |
|---|---|---|---|
| open / defaultOpen | boolean | — | Controlled or uncontrolled. Pass onOpenChange with open. |
| onOpenChange | (open: boolean) => void | — | Fires for every dismissal path: backdrop, Escape, close button. |
| title | ReactNode | — | Required — it is what names the dialog for assistive technology. |
| description | ReactNode | — | Wired as the accessible description. |
| footer | ReactNode | — | Actions, 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. |
| trigger | ReactNode | — | The element that opens it. Omit for a fully controlled dialog. |
Keyboard
| Key | Behaviour |
|---|---|
Escape | Closes the dialog |
Tab / Shift+Tab | Cycles focus inside it — focus cannot leave while open |
| Focus on close | Returns 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.