- Trigger
Hover in / out- When
- trigger, pointer
- Effect
- Opens/closes, delayed by openDelayMs/closeDelayMs (default 0, legacy immediate).
Docs Platform
Tooltip
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 { tooltipMachine, tooltipSelectors, type TooltipCommand } from "@grassroot/ui-machines/tooltip" const changes: TooltipCommand[] = []const service = createService(tooltipMachine, { input: { defaultOpen: false, openDelayMs: 300, closeDelayMs: 150 }, runCommand: (change) => { changes.push(change) },}) service.start()service.send({ type: "POINTER_ENTER" }) changes.at(-1) // { type: "statechart.schedule", key: "tooltip.open", event: { type: "OPEN", reason: "delay" }, delayMs: 300 }tooltipSelectors.open(service.getSnapshot()) // false, still pending, no state change yet service.send({ type: "FOCUS" }) // focus opens immediately, no delay ever (APG + legacy)tooltipSelectors.open(service.getSnapshot()) // trueservice.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:
POINTER_ENTER,POINTER_LEAVE,FOCUS,BLUR,ESCAPE,OPEN{ reason },CLOSE{ reason }. -
Open binding: one state-backed binding,
open/defaultOpen, the same shape Switch uses. 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 Select, Tabs and Accordion follow. -
Reasons: every change request carries
"pointer" | "focus" | "escape" | "delay" | "programmatic", so consumers can distinguish how open state changed, including"delay"for a hover-delay timer firing. -
Commands:
OPEN_CHANGE_REQUEST{ open, reason }, the single change event, plus the scheduled-delivery commandsstatechart.scheduleandstatechart.cancelthat carry a pending hover-delay open or close. The machine never owns a timer itself; aScheduleroutside the machine (native in adapters, virtual in tests) is what actually waits and delivers the event. - Forms: none.
-
Disabled:
disabled: trueguardsPOINTER_ENTER/FOCUS/OPENas pure no-ops. It does not force-close an already-open tooltip whose input becomes disabled mid-session. It stays open until a close event.
Hover delay
openDelayMs and closeDelayMs default to
0, the legacy-immediate setting: hover opens and leave closes with no
wait, exactly like the pre-machine implementation. Set either above zero
and that transition becomes a scheduled event instead of an immediate
one:
-
Hovering in while closed with a nonzero
openDelayMsschedules the open (reason"delay") rather than opening immediately, so no state change happens until the timer fires. -
Hovering out always cancels any pending open first; if the tooltip is
open and
closeDelayMsis nonzero it schedules the close the same way, otherwise it closes immediately (reason"pointer"). - Focus always opens immediately, with no delay ever. That is a WAI-ARIA APG requirement (a keyboard user shouldn't have to wait out a hover timer to discover a tooltip) and also matches legacy behaviour. Blur closes immediately for the same reason.
-
Escapewhile open cancels every pending timer and closes immediately (reason"escape"). - Rapid enter/leave/enter is safe by construction, because each new schedule replaces the previous one under the same key rather than stacking timers.
Anatomy
The headless layer's parts, with identical part names across every framework adapter:
Tooltip.Root data-scope="tooltip" data-part="root"
Tooltip.Trigger data-part="trigger" (aria-describedby -> content id)
Tooltip.Content data-part="content" (role=tooltip; ALWAYS mounted)
Tooltip.Arrow data-part="arrow" (aria-hidden decoration)
Root, Trigger and Content carry data-state="open" | "closed";
Root and Content also carry data-side mirroring the
side input. That is a styling hook only: positioning stays CSS as
it always has, and the machine never branches on it. IDs are
deterministic from the part scope seed.
Content stays mounted whether open or closed. Unlike
Accordion's closed panels (which stay in the DOM behind
hidden), Tooltip's Content is never removed and never
carries hidden. It keeps the legacy always-mounted
presence model, with aria-hidden (stringified
"true"/"false") reflecting open state instead.
That keeps the opacity/visibility transition a pure CSS concern with no
DOM-presence flicker.
The Trigger is the consumer's own element or composition slot, and aria-describedby points at the content id, applied through
each framework's native composition mechanism (React/Solid's
asChild, Vue's slot-cloning, Svelte's snippet, Angular's
host directive) rather than a wrapper element. There is no floating or
measure machinery in this layer. Positioning is deliberately left to
CSS, matching legacy.
Keyboard
Legacy-compatible: hover in/out and focus in/out toggle the tooltip;
Escape (keydown on the root) closes it. The hover delays
are the one versioned addition, layered entirely on top through the
scheduler commands above, and they never change what triggers open or close, only when.
Hover in / outFocus / BlurEscape- Trigger
Focus / Blur- When
- trigger, keyboard or programmatic
- Effect
- Opens/closes immediately, never delayed, even with a nonzero openDelayMs (WAI-ARIA APG requirement, also the legacy behaviour).
- Trigger
Escape- When
- root, open
- Effect
- Closes immediately and cancels any pending delay (legacy-compatible).
Accessibility
Content is role="tooltip", always mounted, with
aria-hidden reflecting open state, so
aria-describedby on the trigger always resolves to a real
element whether the tooltip is currently shown or not. Focus opens the
tooltip immediately with no delay, so keyboard users never have to wait
out a hover timer meant for pointer users. That is the one place the hover delay inputs deliberately do not apply. connectTooltip and
every prop getter are callable with no DOM present. Part-scope
registration happens on mount, and IDs are deterministic per scope
seed, so server-rendered markup and the first client render agree. No
timers exist until a real user event schedules one.
Evidence & further reading
-
tooltip.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/tooltip.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 Tooltip, applied to a component that isn't one of the shipped machines. -
tooltip.spec.md, the reviewable anatomy/ARIA/keyboard/focus/forms/SSR contract; code must match it, and where they disagree the spec wins. - Accordion: the four-layer walkthrough, the closest sibling 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.