hono-preact

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

Subscribe

Get notified when we ship something new.

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.

PropTypeDefaultDescription
openboolean-Controlled open state. Pair with onOpenChange.
defaultOpenbooleanfalseInitial open state when uncontrolled.
onOpenChange(open: boolean) => void-Called when the dialog requests an open or close.
childrenComponentChildren-The trigger and popup.

Dialog.Trigger

Opens the dialog on click. Default element <button type="button">.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Trigger label.
...propsJSX.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.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
aria-labelstring-Accessible name when there is no Dialog.Title.
closeOnBackdropClickbooleantrueClose when the backdrop (the dialog's own area) is clicked.
childrenComponentChildren-Title, description, body, and close controls.
...propsJSX.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>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-Title text.
...propsJSX.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>.

PropTypeDefaultDescription
renderRenderProp-Compose or replace the element.
childrenComponentChildren-Description text.
...propsJSX.HTMLAttributes<HTMLParagraphElement>-Forwarded to the element.

Dialog.Close

Closes the dialog on click. Default element <button type="button">.

PropTypeDefaultDescription
renderRenderProp<{ open: boolean }>-Compose or replace the element.
childrenComponentChildren-Button label.
...propsJSX.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 ::backdrop covering the page.
  • Screen readers announce the dialog's name, and its description when present.