- Trigger
Escape- When
- anywhere, open (dismissable)
- Effect
- Closes the topmost dialog only. Owned by the layer stack, top-layer-only (legacy-compatible).
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
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()) // truechanges.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 oneOPEN_CHANGE_REQUEST; controlled variants leave the authoritative state unchanged and emit the request with the proposed value. Flipping controlled ↔ uncontrolled after start produces abinding-mode-changediagnostic, 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(defaulttrue) is a machine-owned guard. When it'sfalse,escapeandoutside-clickCLOSE 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:
OPENwhile already open andCLOSEwhile 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+Tabat 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: hiddenon 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.
EscapePointerdown on backdropTab / Shift+Tab- 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
-
dialog.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/dialog.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount, including axe checks for "no violations while closed" and "no violations while open". -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for Dialog, applied to a component that isn't one of the shipped machines. -
dialog.spec.md, the reviewable anatomy/ARIA/keyboard/focus/managers/forms/SSR contract; code must match it, and where they disagree the spec wins. - Tooltip: the four-layer walkthrough, the closest sibling documentation contract, worked through Tooltip's own anatomy and its scheduled-delay timers.
- Accordion: the four-layer walkthrough, the same documentation contract, worked through Accordion's own anatomy.
- Select: the four-layer walkthrough, the same documentation contract, worked through Select's own anatomy.
- Switch: three-layer walkthrough, the platform's smallest teaching example, one layer currently real.