Primitives Overlays
Hover card
A preview that appears when a pointer rests on a link, and at once when the link gets keyboard focus. The card is a native hint popover right after its trigger; unlike a tooltip it may hold links and buttons and is reachable with Tab. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1353 B brotli · 1530 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
popover="hint",showPopover({ source }),position-area,pointerover- Shared listeners
- pointerover, pointerout, focusin, focusout, keydown
- Per-instance listeners
- none
- Lazy state
- none
- Native base
popover="hint" next to its trigger
On this page
Example
The trigger’s destination: a hover card only previews it.
<div>
Reviewed by
<span data-slean="hover-card">
<a href="/people/ada" data-slean-part="trigger">Ada Lovelace</a>
<div popover="hint" data-slean-part="content">
<p><strong>Ada Lovelace</strong> · Analyst, London</p>
<p>Notes on the analytical engine, 1843.</p>
<a href="/people/ada/notes">Read the notes</a>
</div>
</span>
on 28 September.
</div><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="hover-card".
import '@svelte-lean/styles/hover-card.css';
let { person }: { person: { name: string; role: string; href: string } } = $props();
</script>
<span data-slean="hover-card">
<a href={person.href} data-slean-part="trigger">{person.name}</a>
<div popover="hint" data-slean-part="content">
<p><strong>{person.name}</strong> · {person.role}</p>
<a href="{person.href}/notes">Read the notes</a>
</div>
</span>Why this implementation exists
A tooltip describes its trigger in a few words; a hover card previews where its trigger leads, with a picture, details and links of its own. That makes it interactive content, so it cannot be a description (aria-describedby) and it must be reachable by keyboard.
The platform provides the pieces: a hint popover in the top layer, which does not close an open menu, and showPopover({ source }), which makes the trigger the invoker so the card follows it in the focus order. The behavior adds what has no trigger in HTML: a hover delay, keeping the card open while the pointer crosses onto it or focus is inside it, and Escape that returns focus without reopening the card.
The browser owns
- the top layer; with popover="hint", closing other hints without closing an open menu
- the trigger as the invoker: the card is next in the focus order after it
- placement next to the trigger with anchor positioning, where supported
Svelte Lean owns
- showing after a hover delay, at once on keyboard focus, never on touch
- keeping the card open while the pointer or focus is on it; one card at a time
- Escape: closing, returning focus from inside the card, not reopening on that focus
- hover-card.css: the card surface, the sides, the entry transition
Usage
Install @svelte-lean/primitives for the behavior and @svelte-lean/styles for the stylesheet. With @svelte-lean/vite the
registration is injected for every static data-slean="hover-card"; without it, import
the register module once.
npm install @svelte-lean/primitivespnpm add @svelte-lean/primitivesyarn add @svelte-lean/primitivesbun add @svelte-lean/primitivesimport '@svelte-lean/primitives/hover-card/register';import '@svelte-lean/styles/hover-card.css';Put the trigger and, right after it, the content in the root. The trigger works on its own: the card only previews its destination, since touch users never see it.
Fill the card on the server, or on the popover’s toggle event when its content is expensive.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <span data-slean="hover-card"> | – | yes | Options: data-slean-side (top, bottom, left, right), data-slean-delay in milliseconds. |
| trigger | <a href> | trigger | yes | Works on its own: it goes where the card previews. |
| content | <div popover="hint"> | content | yes | Right after the trigger; may hold links and buttons. |
Runtime profile
The hover card registers five handlers (pointerover, pointerout, focusin, focusout, keydown) with the shared router and keeps three variables for the page: the open card, one timer and the card Escape dismissed. A thousand cards keep one listener per type (tests/hover-card.test.ts).
Accessibility contract
- The card is supplementary: what it shows is also where the trigger leads.
- Keyboard focus on the trigger shows the card at once; Tab moves into it because the trigger is its invoker, and the card follows the trigger in the DOM as well.
- Escape closes the card; from inside it, focus returns to the trigger and the card stays closed until focus leaves.
- Touch pointers are ignored: a tap follows the link.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | onto the trigger | Shows the card at once |
| Tab | on the trigger, card open | Moves into the card |
| Escape | the card is open | Hides it; from inside, focus returns to the trigger |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Popover API | Baseline 2024 (Chrome 114, Safari 17, Firefox 125) | The card renders in place, hidden by the stylesheet until shown |
popover="hint" | Chrome 133; not Baseline | Falls back to manual; the behavior hides the card itself |
CSS anchor positioning | Chrome 125, Safari 26; not Baseline | The card keeps the platform default placement |
Without JavaScript
The card never shows; the trigger is a working link.
Server rendering
Render the markup as is; the card is hidden until shown.
Before hydration
Before the behavior loads, hovering shows nothing. The first hover after hydration works on the server's markup.
Styling
hover-card.css draws the card surface, anchors it below the trigger's inline start (or on another side), slides it in, and keeps the platform's centered placement where anchor positioning is missing. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | margin-block, margin-inline |
--slean-space-4 | 1rem | max-inline-size, padding |
--slean-border | var(--slean-neutral-6) | border |
--slean-radius-lg | 0.875rem | border-radius |
--slean-surface | oklch(100% 0 0) | background |
--slean-fg | var(--slean-neutral-12) | color |
--slean-shadow-lg | 0 16px 40px oklch(0% 0 0 / 0.18), 0 2px 6px oklch(0% 0 0 / 0.08) | box-shadow |
--slean-text-sm | 0.875rem | font-size |
--slean-leading | 1.5 | line-height |
--slean-space-1 | 0.25rem | translate |
--slean-duration-normal | 160ms | transition |
--slean-ease | cubic-bezier(0.2, 0, 0, 1) | transition |
State selectors the stylesheet targets, all from the platform or ARIA: :popover-open.
Variant attributes: data-slean-side (top, left, right).
Compatibility notes
The Popover API is Baseline 2024. popover="hint" and the source option of showPopover() are Chromium-first; elsewhere the card is a manual popover the behavior hides itself, still after its trigger in the focus order. Anchor positioning is in Chrome and Safari.
Testing
packages/primitives/tests/hover-card.test.tsVitest: delays, hover onto the card, one at a time, touch, Escape and focus, 1000 rootsapps/playground/tests/primitives/widgets.spec.tsPlaywright: tooltip, toast, tree, range slider and number field in Chrome, with axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/hover-card/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/hover-card/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/hover-card/behavior.tsthe behavior definitionpackages/primitives/src/hover-card/register.tsthe registration modulepackages/styles/css/hover-card.cssthe optional stylesheet