- Layer
- Machine
- Package
@grassroot/ui-machines/hover-card- Ships behaviour
- Open binding, hover/focus scheduling, delay timers, no DOM.
Docs Platform
HoverCard
A preview panel that opens on hover and closes when the pointer leaves. It is built on Popover's non-modal trigger, content and layer parts, with Tooltip's scheduled hover-delay handling the timing. Neither of those is reimplemented here: HoverCard is Popover's anatomy with hover and focus events wired onto it.
Explore the layers
Machine (@grassroot/ui-machines/hover-card)
is pure state: no DOM, no framework, no styling.
Headless
(@grassroot/ui-headless-<framework>/hover-card) adds
Popover's plain dialog anatomy with pointer/focus handlers on top of
the same machine, per framework. Styled
(@grassroot/ui-<framework>) is Grassroot's own
visual recipe built on the headless layer, with every framework's
legacy timer/Escape-listener/unmount-on-close bindings deleted in favor
of it. Vanilla (mountHoverCard, from
@grassroot/ui-headless-core/hover-card) is the
framework-free mount helper.
@grassroot/ui-machines/hover-card@grassroot/ui-headless-{react,vue,solid,svelte,angular}/hover-card@grassroot/ui-headless-core/hover-card (mountHoverCard)@grassroot/ui-{react,vue,solid,svelte,angular}- Layer
- Headless
- Package
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/hover-card- Ships behaviour
- Popover's plain dialog anatomy plus pointer/focus event wiring, no visual recipe.
- Layer
- Vanilla
- Package
@grassroot/ui-headless-core/hover-card(mountHoverCard)- Ships behaviour
- The same headless contract with no framework runtime at all.
- Layer
- Styled
- Package
@grassroot/ui-{react,vue,solid,svelte,angular}- Ships behaviour
- Grassroot's own preview-card visual recipe, tokens and classes only.
The legacy React/Vue/Solid/Svelte/Angular wrappers used to own their
own timers, their own document Escape listeners, and their own
unmount-on-close rendering. All of that is deleted by this component;
the public styled prop names stay familiar:
openDelay, closeDelay,
defaultOpen, side, and
className/contentClassName where a framework
already exposed them. Each adapter maps those legacy names onto
the machine's own openDelayMs/closeDelayMs
inputs.
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.
HoverCardInput: open? / defaultOpen? (ONE state-backed binding)
openDelayMs?: number (default 200)
closeDelayMs?: number (default 120)
side?: "top" | "bottom" (styling hook only)
StateValue: "open" | "closed"
Context: { mode: "controlled" | "uncontrolled" }
Events: POINTER_ENTER / POINTER_LEAVE
FOCUS / BLUR
OPEN { reason } / CLOSE { reason }
Commands: OPEN_CHANGE_REQUEST { open, reason }
statechart.schedule { key, event, delayMs }
statechart.cancel { key }
-
Reasons: every change request carries one of
"pointer" | "focus" | "escape" | "delay" | "programmatic", the same reason vocabulary Tooltip uses, so consumers can tell how the card actually opened or closed. -
Open binding: the same
open/defaultOpenshape every layered overlay uses, following the platform's usual S12 rule exactly. An uncontrolled action commits and emits one request, a controlled action leaves the authoritative value unchanged and emits one request, sync adopts the controlled value without a request, and a post-start mode flip emits the standardbinding-mode-changediagnostic and preserves state. -
Scheduled keys are exactly
"hovercard.open"and"hovercard.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, the same contract Tooltip's hover delays run on. - Forms: none.
Hover delay
openDelayMs defaults to 200 and
closeDelayMs defaults to 120. HoverCard is the one component in the platform where the delay itself defaults to
nonzero, since a preview card popping open on every incidental mouse
pass would be worse than a tooltip doing the same.
-
POINTER_ENTERwhile closed schedulesOPEN { reason: "delay" }under"hovercard.open"whenopenDelayMs > 0; with0it opens immediately with reason"pointer". -
POINTER_LEAVEalways cancels"hovercard.open"first. While open, it schedulesCLOSE { reason: "delay" }under"hovercard.close"whencloseDelayMs > 0; with0it closes immediately with reason"pointer". While closed, cancellation is the only output. - Same-key scheduling replaces the previous pending event, so rapid enter/leave/enter never stacks two timers waiting to fire.
- Focus always opens immediately, with no delay, cancelling any pending close; blur always closes immediately, cancelling any pending open, the same proven pattern Tooltip uses, so keyboard users never wait out a hover timer meant for pointer users.
-
Escape is owned by the non-modal Popover layer,
which sends
CLOSE { reason: "escape" }; the machine closes immediately and cancels both scheduled keys in one step. There is no per-framework document keydown listener anywhere in this component. - Opening an already-open card and closing an already-closed card are true no-ops.
Anatomy
HoverCard uses the Popover anatomy directly and does not reinvent it:
HoverCard.Root service/scope owner and the inline hover containment wrapper;
data-scope="hover-card" data-part="root"; owns no dismissal logic
HoverCard.Trigger the consumer's trigger/composition surface; Popover's
aria-haspopup="dialog", aria-expanded, aria-controls;
HoverCard adds pointer/focus handlers on top
HoverCard.Content Popover's plain non-modal panel (role="dialog", deterministic id,
data-side, data-state); rendered only while open
Every part carries data-scope="hover-card" and its
data-part. Root is the one HoverCard-specific addition
over Popover's own anatomy. It exists purely because the legacy
component keeps Trigger and Content inside one inline hover surface,
not because HoverCard needs a different service model. There are no
menu roles anywhere, and HoverCard never moves focus on open or close.
Keyboard and pointer
Pointer enter/leave use the scheduled delays above. Focus opens immediately; blur closes immediately. Escape dismisses the top HoverCard layer. Touch behaviour is unaddressed, a known and deliberately-recorded gap this component inherits directly from Tooltip's own stance, not a new omission.
Forms
None.
Accessibility
Trigger/content use the Popover relation exactly: trigger controls the
deterministic content id through aria-controls, and
exposes aria-expanded and
aria-haspopup="dialog". Content is a plain
role="dialog" preview: not a menu, not a tooltip, not
modal, and it never traps focus. side is emitted as
data-side on the positioner/content surfaces, a styling
hook only. connectHoverCard and every prop getter are
DOM-free and SSR-safe; the Popover layer is a mount-time construct
only, and no native timer exists until a real browser event schedules
one. All five adapters start the service after mount and stop it
exactly once on unmount.
Evidence & further reading
-
hover-card.contract.ts, the shared browser-contract suite, implemented per adapter in each framework's ownbrowser/hover-card.spec.ts(React, Vue, Solid, Svelte, Angular) plus the core vanilla mount. -
hover-card.spec.md, the reviewable anatomy/ARIA/keyboard/scheduling/forms/SSR contract; code must match it, and where they disagree the spec wins. It records the legacy behaviour mapping (pointer/focus/blur/escape → machine event) so compatibility and versioned additions stay distinct. -
Build your own component on
@grassroot/ui-headless-core, the same machine → part scope → connect → commands recipe this page walks through for HoverCard, applied to a component that isn't one of the shipped machines. - Popover: the trigger/content/layer seam HoverCard is built directly on
- Tooltip: the scheduled-delay pattern HoverCard's hover timing reuses