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#
import { Dialog } from 'hono-preact-ui';
// A styled Dialog used as the live demo on the docs page. The styling lives in
// apps/site/src/styles/root.css (.docs-dialog*) and mirrors the copyable CSS
// example below it, so what you see is what you copy.
export function DialogDemo() {
return (
<Dialog.Root>
<Dialog.Trigger class="docs-dialog-trigger">Open dialog</Dialog.Trigger>
<Dialog.Popup class="docs-dialog">
<Dialog.Title>Subscribe</Dialog.Title>
<Dialog.Description>
Get notified when we ship something new.
</Dialog.Description>
<div class="docs-dialog__actions">
<Dialog.Close class="docs-dialog-close">Close</Dialog.Close>
</div>
</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;
}
}<Dialog.Popup
class="fixed top-1/2 left-1/2 [transform:translate(-50%,-50%)]
w-[min(28rem,calc(100vw-2rem))] max-h-[calc(100dvh-2rem)] overflow-auto
rounded-2xl border border-zinc-200 bg-white p-6 text-zinc-900 shadow-xl
opacity-100 transition-[opacity,transform] duration-[160ms] ease-out
starting:opacity-0 starting:[transform:translate(-50%,calc(-50%_+_8px))_scale(0.98)]
data-[state=closed]:opacity-0 data-[state=closed]:[transform:translate(-50%,calc(-50%_+_8px))_scale(0.98)]
motion-reduce:transition-none
backdrop:bg-black/50 backdrop:backdrop-blur-sm
dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-100"
>
{/* Dialog.Title, Dialog.Description, Dialog.Close */}
</Dialog.Popup>API reference#
Every part accepts a render prop for composition (see
renderElement) 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:
renderElement,
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.