Docs Platform

Dialog

One behaviour contract, four layers. Pick a layer, and a framework where that applies, and the page swaps what it shows you: the state machine, its accessible DOM and ARIA translation, the styled component, or the framework-free mount. What it means stays put. The behaviour contract further down holds whichever layer you picked, which is why it's written out once instead of four times.

Explore the layers

Machine is pure state: no DOM, no framework, no styling. Headless adds accessible DOM/ARIA on top of the same machine, per framework. Styled is Grassroot's own visual recipe built on the headless layer. Vanilla is the framework-free mount helper, for script islands and anywhere else with no framework to hand.

Install

npm i @grassroot/ui-machines

ts
import { createService } from "@grassroot/statechart"
import { dialogMachine, dialogSelectors, type DialogCommand } from "@grassroot/ui-machines/dialog"
const changes: DialogCommand[] = []
const service = createService(dialogMachine, {
input: { defaultOpen: false, dismissable: true },
runCommand: (change) => { changes.push(change) },
})
service.start()
service.send({ type: "OPEN", reason: "trigger" })
dialogSelectors.open(service.getSnapshot()) // true
changes.at(-1) // { type: "OPEN_CHANGE_REQUEST", open: true, reason: "trigger" }
service.send({ type: "CLOSE", reason: "escape" })
dialogSelectors.open(service.getSnapshot()) // false
// dismissable: false turns escape/outside-click into no-ops — only
// close-trigger (or a programmatic CLOSE) can close it.
service.stop()

Behaviour contract

This is the one thing every layer agrees on. The machine decides it, the headless layer exposes it as DOM/ARIA, and the styled layer inherits it unchanged.

  • Events: OPEN { reason: "trigger" | "programmatic" }, CLOSE { reason: "escape" | "outside-click" | "close-trigger" | "programmatic" }.
  • Open binding: one state-backed binding, open / defaultOpen, the same shape Switch and Tooltip use. Uncontrolled interaction commits and emits one OPEN_CHANGE_REQUEST; controlled variants leave the authoritative state unchanged and emit the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces a binding-mode-change diagnostic, the same rule every other layered component follows. Legacy Dialog was controlled-only, so controlled mode IS the legacy path; defaultOpen (uncontrolled) is a versioned addition.
  • Dismissable gate: dismissable (default true) is a machine-owned guard. When it's false, escape and outside-click CLOSE events are pure no-ops. This used to be duplicated per adapter; now it lives in one place.
  • Commands: OPEN_CHANGE_REQUEST { open, reason }, the single change event, exactly one per committed or requested change.
  • Forms: none.
  • No-ops: OPEN while already open and CLOSE while already closed are true no-ops, and no command is emitted.

Dismissal & focus

The machine only decides open/closed and the dismissable gate. Everything about how dismissal and focus actually behave in the DOM is owned by three headless-core managers, wired by the connector on open-commit and released on close or unmount. None of it is reimplemented per adapter, and none of it lives in the machine.

  • Layer stack owns Escape and outside-click, top-layer-only. Both are document-level listeners registered by the layer stack, not a per-adapter scrim mousedown or panel keydown handler, and the backdrop itself needs no click handler at all. With multiple dialogs open, Escape and an outside pointerdown only ever dismiss the topmost one; an untouched dialog underneath stays open.
  • Focus trap moves focus to the first tabbable element in content the instant it opens, wraps Tab/Shift+Tab at either end of content's tabbable elements, reclaims focus back into content if anything moves it out programmatically, and restores focus to the trigger the instant the dialog closes. All four behaviours are oracle-faithful to the legacy implementation.
  • Scroll lock is ref-counted. Opening a dialog sets overflow: hidden on the document; nesting a second dialog inside it doesn't fight over that style, and the lock only releases once every open dialog has closed, the same ref-counting behaviour legacy nested dialogs relied on.

Anatomy

The headless layer's parts, with identical part names across every framework adapter:

Dialog.Trigger       data-part="trigger"       (optional; sends OPEN reason=trigger; aria-haspopup="dialog", aria-expanded)
Dialog.Backdrop      data-part="backdrop"      (the scrim)
Dialog.Positioner    data-part="positioner"    (the fixed overlay wrapper)
Dialog.Content       data-part="content"       (role="dialog" aria-modal="true")
Dialog.Title         data-part="title"         (id -> aria-labelledby)
Dialog.Description   data-part="description"   (id -> aria-describedby)
Dialog.CloseTrigger  data-part="close-trigger" (sends CLOSE reason=close-trigger)

All rendered parts carry data-scope="dialog", data-part, and data-state="open" | "closed". There is no Dialog.Root DOM element. Root is purely the context/service owner and renders nothing itself.

Backdrop, Positioner and Content, plus everything mounted inside Content, render only while open. This is the same unmount-on-close model as Accordion's closed panels and Tooltip's arrow, with no exit animation, preserving legacy behaviour exactly. Trigger is the one part that stays mounted regardless of open state. aria-labelledby/aria-describedby on Content only appear when the corresponding Title/Description part is actually rendered, so a titleless or descriptionless dialog never points at an id that doesn't exist.

Portalling stays each framework's own native mechanism: React portals, Vue's Teleport, Svelte's actions, Solid's Portal, Angular's CDK-free direct DOM append. The connector itself never portals anything; that choice is left entirely to the adapter.

Keyboard

Legacy-compatible throughout: Escape and a backdrop click close a dismissable dialog, Tab cycles inside content with the legacy wrap maths, and focus can never escape the panel while it's open.

Trigger
When
Effect
Escape
anywhere, open (dismissable)
Closes the topmost dialog only. Owned by the layer stack, top-layer-only (legacy-compatible).
Pointerdown on backdrop
open (dismissable)
Closes with reason "outside-click". Also owned by the layer stack, not a scrim click handler (legacy-compatible).
Tab / Shift+Tab
content, open
Cycles through content's tabbable elements, wrapping at either end, so focus can never escape (legacy wrap math, reclaimed if it does).
Trigger
Escape
When
anywhere, open (dismissable)
Effect
Closes the topmost dialog only. Owned by the layer stack, top-layer-only (legacy-compatible).
Trigger
Pointerdown on backdrop
When
open (dismissable)
Effect
Closes with reason "outside-click". Also owned by the layer stack, not a scrim click handler (legacy-compatible).
Trigger
Tab / Shift+Tab
When
content, open
Effect
Cycles through content's tabbable elements, wrapping at either end, so focus can never escape (legacy wrap math, reclaimed if it does).

Accessibility

Content carries role="dialog" and aria-modal="true" whenever it's mounted. aria-labelledby and aria-describedby are conditionally absent, and only written when a Title or Description part is actually present, never pointed at an id that doesn't exist. The aria-describedby wiring itself is a versioned accessibility fix over the legacy behaviour: the legacy implementation rendered a description but never connected it to the panel with ARIA at all. Everything else in this section (initial focus, the Tab-trap, escaped-focus reclaim, focus return on close, and the ref-counted scroll lock) is covered above under "Dismissal & focus", since none of it is ARIA attributes so much as manager-owned DOM behaviour.

Evidence & further reading