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

ts
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()) // true
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: 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 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 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 commands statechart.schedule and statechart.cancel that carry a pending hover-delay open or close. The machine never owns a timer itself; a Scheduler outside the machine (native in adapters, virtual in tests) is what actually waits and delivers the event.
  • Forms: none.
  • Disabled: disabled: true guards POINTER_ENTER/FOCUS/OPEN as 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 openDelayMs schedules 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 closeDelayMs is 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.
  • Escape while 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.

Trigger
When
Effect
Hover in / out
trigger, pointer
Opens/closes, delayed by openDelayMs/closeDelayMs (default 0, legacy immediate).
Focus / Blur
trigger, keyboard or programmatic
Opens/closes immediately, never delayed, even with a nonzero openDelayMs (WAI-ARIA APG requirement, also the legacy behaviour).
Escape
root, open
Closes immediately and cancels any pending delay (legacy-compatible).
Trigger
Hover in / out
When
trigger, pointer
Effect
Opens/closes, delayed by openDelayMs/closeDelayMs (default 0, legacy immediate).
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