Dialog
An accessible modal dialog built on the native <dialog> element. The browser
supplies the focus trap, background inert, top layer, and Escape-to-close;
@hono-preact/ui adds the ARIA wiring, open-state, and render-prop
composition. It ships unstyled: style it through the data-state contract.
Demo
Usage
import { Dialog } from '@hono-preact/ui';
export function Subscribe() {
return (
<Dialog.Root>
<Dialog.Trigger>Open dialog</Dialog.Trigger>
<Dialog.Popup>
<Dialog.Title>Subscribe</Dialog.Title>
<Dialog.Description>Get notified when we ship.</Dialog.Description>
<Dialog.Close>Close</Dialog.Close>
</Dialog.Popup>
</Dialog.Root>
);
}
Styling
Every part exposes data-state="open" | "closed". The popup is a native
<dialog>, so its backdrop is the ::backdrop pseudo-element and its entry
animates with @starting-style (degrading to no animation where unsupported).
The demo above uses the styles below; copy a starting point in either flavor:
/* Center the modal and give it a card surface. position + transform restore
centering even when a reset (e.g. Tailwind preflight) clears the UA margin
that normally centers a modal <dialog>. Keep centering in `transform`, not the
standalone `translate` property: a rule that sets both can have its `translate`
dropped by a CSS minifier, silently un-centering the dialog in a production
build. */
dialog[data-state='open'] {
position: fixed;
top: 50%;
left: 50%;
width: min(28rem, calc(100vw - 2rem));
max-height: calc(100dvh - 2rem);
overflow: auto;
padding: 1.5rem;
border: 1px solid #e4e4e7;
border-radius: 14px;
background: #fff;
color: #18181b;
box-shadow:
0 10px 15px -3px rgb(0 0 0 / 0.2),
0 4px 6px -4px rgb(0 0 0 / 0.2);
opacity: 1;
transform: translate(-50%, -50%);
transition:
opacity 160ms ease,
transform 160ms ease;
}
@starting-style {
dialog[data-state='open'] {
opacity: 0;
transform: translate(-50%, calc(-50% + 8px)) scale(0.98);
}
}
dialog::backdrop {
background: rgb(0 0 0 / 0.5);
backdrop-filter: blur(2px);
opacity: 1;
transition: opacity 160ms ease;
}
@starting-style {
dialog[open]::backdrop {
opacity: 0;
}
}
@media (prefers-color-scheme: dark) {
dialog[data-state='open'] {
border-color: #27272a;
background: #18181b;
color: #fafafa;
}
}
dialog[data-state='closed'] {
animation: dialog-out 160ms ease-in forwards;
}
@keyframes dialog-out {
to {
opacity: 0;
transform: translate(-50%, calc(-50% + 8px)) scale(0.98);
}
}
dialog[data-state='closed']::backdrop {
animation: dialog-backdrop-out 160ms ease-in forwards;
}
@keyframes dialog-backdrop-out {
to {
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
dialog[data-state='open'],
dialog[data-state='closed'],
dialog::backdrop,
dialog[data-state='closed']::backdrop {
transition: none;
animation: none;
}
}API reference
Every part accepts a render prop for composition (see
useRender) and forwards unknown props to the
element it renders. Dialog.Trigger, Dialog.Popup, and Dialog.Close also
expose data-state="open" | "closed" and pass { open } to a render function.
Dialog.Root
Provides state and id wiring to the parts. Renders only its children.
| Prop | Type | Default | Description |
|---|---|---|---|
open | boolean | - | Controlled open state. Pair with onOpenChange. |
defaultOpen | boolean | false | Initial open state when uncontrolled. |
onOpenChange | (open: boolean) => void | - | Called when the dialog requests an open or close. |
children | ComponentChildren | - | The trigger and popup. |
Dialog.Trigger
Opens the dialog on click. Default element <button type="button">.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ open: boolean }> | - | Compose or replace the element. |
children | ComponentChildren | - | Trigger label. |
...props | JSX.HTMLAttributes<HTMLButtonElement> | - | Forwarded to the element; a passed onClick runs before the dialog opens. |
Sets aria-haspopup="dialog", aria-expanded, aria-controls, id, and data-state.
Dialog.Popup
The native <dialog>. Renders closed on the server and calls showModal() on
the client when opened.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ open: boolean }> | - | Compose or replace the element. |
aria-label | string | - | Accessible name when there is no Dialog.Title. |
closeOnBackdropClick | boolean | true | Close when the backdrop (the dialog's own area) is clicked. |
children | ComponentChildren | - | Title, description, body, and close controls. |
...props | JSX.HTMLAttributes<HTMLDialogElement> | - | Forwarded to the element. |
Sets id, data-state, aria-labelledby (the Title) or aria-label, and
aria-describedby (only when a Dialog.Description is present). role="dialog"
and aria-modal="true" come implicitly from showModal().
Dialog.Title
The dialog's accessible name, wired to the popup's aria-labelledby. Default
element <h2>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace the element. |
children | ComponentChildren | - | Title text. |
...props | JSX.HTMLAttributes<HTMLHeadingElement> | - | Forwarded to the element. |
Dialog.Description
Optional supporting text, wired to the popup's aria-describedby while it is
rendered. Default element <p>.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp | - | Compose or replace the element. |
children | ComponentChildren | - | Description text. |
...props | JSX.HTMLAttributes<HTMLParagraphElement> | - | Forwarded to the element. |
Dialog.Close
Closes the dialog on click. Default element <button type="button">.
| Prop | Type | Default | Description |
|---|---|---|---|
render | RenderProp<{ open: boolean }> | - | Compose or replace the element. |
children | ComponentChildren | - | Button label. |
...props | JSX.HTMLAttributes<HTMLButtonElement> | - | Forwarded to the element; a passed onClick runs before the dialog closes. |
Primitives
@hono-preact/ui also exports the building blocks the parts use, for composing
your own components. Each has its own page with examples:
useRender,
useControllableState, and
mergeRefs.
Accessibility
A dialog must have an accessible name: render a Dialog.Title (wired through
aria-labelledby) or pass aria-label to Dialog.Popup. A Dialog.Description
is wired through aria-describedby when present.
Because the popup is a native modal <dialog>, the platform handles the rest:
- Focus moves into the dialog when it opens and is trapped until it closes.
- Background content is
inert: not focusable and hidden from the accessibility tree. - Escape closes the dialog and focus returns to the trigger.
- The dialog renders in the top layer, above all other content, with the
::backdropcovering the page. - Screen readers announce the dialog's name, and its description when present.