Dialog
Modal built on the native <dialog> element. The browser already knows how to
do this properly — it traps focus, closes on Escape, makes the rest of the page
inert, and renders on the top layer above every stacking context.
That is the whole reason PresetUI can ship a modal without a focus-trap library. Try tabbing through the demo below with the dialog open: focus never escapes it, and Escape closes it.
Props
| Prop | Values | Default | Description |
|---|---|---|---|
open | boolean | required | Whether the dialog is showing. |
onClose | function | required | Called for every dismissal. |
title | node | — | Heading shown next to the close button. |
size | "sm", "md", "lg" | "md" | Maximum width of the panel. |
className | string | "" | Appended after the component's own classes. |
Common patterns
const [open, setOpen] = useState(false)
<Button onClick={() => setOpen(true)}>Delete project</Button>
<Dialog open={open} onClose={() => setOpen(false)} title="Delete this project?">
This cannot be undone.
<div className="mt-6 flex justify-end gap-2">
<Button size="sm" variant="outline" onClick={() => setOpen(false)}>
Keep it
</Button>
<Button size="sm" variant="danger" onClick={confirmDelete}>
Delete project
</Button>
</div>
</Dialog>Notes
onClose fires for every dismissal — the close button, Escape, and a click
on the backdrop. Always set your state to closed there, or the dialog will be
shut while your open prop still says otherwise, and the next open won't work.
The backdrop is the <dialog> element itself, which is why the click handler
compares event.target against the element: a click on the content bubbles up
to the same node and would otherwise close the dialog.
Style the backdrop with the backdrop: variant — this component uses
backdrop:bg-black/50.
There is no built-in animation. Native dialogs need @starting-style and
transition-behavior: allow-discrete to animate in and out; that is a few
lines of CSS in your stylesheet rather than something this file should carry.