Dialog

A modal dialog, rendered as <dialog> and opened with showModal(). The top layer handles stacking, the browser handles the focus trap and the inert background, ::backdrop handles the overlay.

import { Dialog } from '@apostel/bedrock'
// or, for a veto: import { Dialog } from '@apostel/bedrock/controlled'
Loading demo…
Source
import { useState } from 'react'
import { Dialog } from '../../src/index'

/**
 * The trigger is an invoker: it carries `commandfor` and `command`, and the
 * browser opens the dialog. Nothing here listens for a click.
 *
 * Type into the field, close, and reopen — it is empty again, because closed
 * content unmounts. No effect resets it.
 */
export default function DialogDemo() {
  const [saved, setSaved] = useState('')

  return (
    <>
      <Dialog.Root>
        <Dialog.Trigger>Rename project</Dialog.Trigger>
        <Dialog.Content>
          <Dialog.Title>Rename project</Dialog.Title>
          <Dialog.Description>This is shown to everyone on the team.</Dialog.Description>
          <form
            method="dialog"
            onSubmit={(event) => setSaved(new FormData(event.currentTarget).get('name') as string)}
          >
            <div className="demo-row">
              <input name="name" placeholder="New name" autoComplete="off" />
            </div>
            <Dialog.Close>Cancel</Dialog.Close> <button type="submit">Save</button>
          </form>
        </Dialog.Content>
      </Dialog.Root>
      {saved ? <output>saved: {saved}</output> : null}
    </>
  )
}

Anatomy

<Dialog.Root>
  <Dialog.Trigger />
  <Dialog.Content>
    <Dialog.Title />
    <Dialog.Description />
    <Dialog.Close />
  </Dialog.Content>
</Dialog.Root>

There is no Portal and no Overlay. Both are gone rather than renamed — <dialog> is in the top layer, so it is above everything regardless of where it sits in the tree, and its backdrop is a pseudo-element rather than a node.

Dialog.Root

See two roots. Renders no element.

Dialog.Trigger

Renders <button type="button" commandfor command="show-modal">.

prop type notes
asChild boolean The child must render a <button>. Enforced at mount.

Everything else is forwarded to the button.

The commandfor wiring is applied after your props, so it cannot be overridden — an unwired trigger is a broken trigger, not a customisation. type="button" is set for the same reason.

aria-expanded and, while open, aria-controls are written by hand.

That was not the original plan — the reasoning was that a hand-written attribute goes stale as soon as something else closes the dialog. Two things changed it: Chrome gives a popover invoker implicit aria-expanded and gives a dialog invoker nothing, and the root now tracks the DOM's open state anyway for content mounting. Both attributes come from that same state, so they cannot disagree with the element they describe.

Dialog.Content

Renders <dialog data-bedrock-dialog>.

Takes every <dialog> prop except the ones that would break the wiring:

prop behaviour
id Not forwarded. The trigger's commandfor points at it.
aria-labelledby Defaults to the Dialog.Title id. Pass undefined to unset.
aria-describedby Merged with the Dialog.Description id, deduped. Pass undefined to unset.
asChild Supported. The child must render a <dialog>, checked at mount.

The <dialog> element is always rendered — the trigger's commandfor must resolve to something — but its children are mounted only while it is open.

That is what makes the uncontrolled root genuinely uncontrolled: a half-typed form inside a dialog is gone when it closes, because the form is gone, not because anything reset it. No onOpenChange handler, no key bumping, no useEffect.

The timing is deliberate in three places, each tested:

moment what happens
beforetoggle children mount — before the frame that paints the dialog, so it is never briefly empty, and showModal() can focus a real control
a refused open (controlled) the flag is put straight back; nothing mounts
after toggle closed children stay until the exit animation finishes, then unmount. Reopen inside that window and the subtree is reused rather than rebuilt

Server-rendered markup is the exception: content is rendered on the server and on the hydrating render that has to match it, so a page whose JavaScript never arrives still has a complete, working dialog. It is only after hydration that a closed dialog drops its children.

There is no forceMount. If you need the subtree alive while closed — an animation library driving presence, a video you do not want to reload — hoist that state above the dialog.

Dialog.Title / Dialog.Description

Render <h2> and <p>, with ids derived from the root's id and wired to the dialog. Both take asChild.

Each registers its presence with the root, so aria-labelledby and aria-describedby appear only when there is something for them to point at — a reference to a missing element would leave the dialog with no accessible name at all.

Both are optional and both are strongly recommended. bedrock does not yet warn when Title is missing, which Radix does; see gaps.

Dialog.Close

Renders <button type="button" commandfor command="request-close">, and takes asChild under the same button rule as Trigger.

request-close rather than close so the cancel event fires and stays vetoable.

useDialogTrigger()

The escape hatch. Returns { commandfor, command } for you to spread onto something bedrock refuses to render:

const props = useDialogTrigger()
<ThirdPartyThing {...props} />

Must be called inside a Dialog.Root. No validation, no accessibility guarantees — that is the deal, and it is why it has a name you have to type.

Keyboard

key behaviour
Enter/Space On the trigger, opens. Native button activation.
Escape Closes, via cancel, and is refusable under the controlled root.
Tab Trapped inside the dialog by the browser, not by us.

Focus moves into the dialog on open and returns to the invoker on close — both are UA behaviour for showModal(), and both are why Dialog needs no focus code.

What is not here