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.

Layer
Package
Ships behaviour
Machine
@grassroot/ui-machines/hover-card
Open binding, hover/focus scheduling, delay timers, no DOM.
Headless
@grassroot/ui-headless-{react,vue,solid,svelte,angular}/hover-card
Popover's plain dialog anatomy plus pointer/focus event wiring, no visual recipe.
Vanilla
@grassroot/ui-headless-core/hover-card (mountHoverCard)
The same headless contract with no framework runtime at all.
Styled
@grassroot/ui-{react,vue,solid,svelte,angular}
Grassroot's own preview-card visual recipe, tokens and classes only.
Layer
Machine
Package
@grassroot/ui-machines/hover-card
Ships behaviour
Open binding, hover/focus scheduling, delay timers, no DOM.
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/defaultOpen shape 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 standard binding-mode-change diagnostic and preserves state.
  • Scheduled keys are exactly "hovercard.open" and "hovercard.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, 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_ENTER while closed schedules OPEN { reason: "delay" } under "hovercard.open" when openDelayMs > 0; with 0 it opens immediately with reason "pointer".
  • POINTER_LEAVE always cancels "hovercard.open" first. While open, it schedules CLOSE { reason: "delay" } under "hovercard.close" when closeDelayMs > 0; with 0 it 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