# Hover card

> A preview card a link names with interestfor: shown on hover and keyboard focus, with interactive content reachable with Tab; native where interest invokers ship.

Page: https://svelte-lean.vercel.app/primitives/hover-card · Markdown: https://svelte-lean.vercel.app/primitives/hover-card.md · Index: https://svelte-lean.vercel.app/llms.txt

A preview of where a link leads, shown when the pointer rests on it or it gets keyboard focus; unlike a tooltip it may hold links.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for the static data-slean="hover-card".
	import '@svelte-lean/styles/avatar.css';
	import '@svelte-lean/styles/hover-card.css'; // brings popover.css
</script>

<p>
	The package is on
	<a href="https://www.npmjs.com/package/@svelte-lean/primitives" interestfor="repo-card">npm</a>.
</p>
<div id="repo-card" popover="hint" data-slean="hover-card">
	<div class="repo">
		<span data-slean="avatar" role="img" aria-label="Svelte Lean">
			<span data-slean-part="fallback" aria-hidden="true">SL</span>
		</span>
		<div>
			<p class="name">@svelte-lean/primitives</p>
			<p>Accessible UI primitives for Svelte on native HTML.</p>
			<p class="meta">MIT License</p>
		</div>
	</div>
</div>

<style>
	.repo {
		display: flex;
		gap: 0.75rem;
	}

	.repo p {
		margin: 0 0 0.25rem;
	}

	.name {
		font-weight: 600;
	}

	.meta {
		color: var(--slean-fg-muted);
		font-size: var(--slean-text-xs);
	}
</style>
```

Rest the pointer on the link, then move onto the card: it stays. With the keyboard, Tab to the link; Escape closes the card.

## Examples

### Link previews

```svelte
<script lang="ts">
	import '@svelte-lean/styles/hover-card.css';
</script>

<p>
	Open a <a href="/primitives/dialog" interestfor="dialog-card">dialog</a> for a task that needs
	an answer, or a <a href="/primitives/dialog#drawer" interestfor="drawer-card">drawer</a> when
	the page should stay in view.
</p>
<div id="dialog-card" popover="hint" data-slean="hover-card">
	<strong>Dialog</strong>
	<p>A modal window over the page, on the native dialog element.</p>
	<a href="/primitives/dialog">Open the page</a>
</div>
<div id="drawer-card" popover="hint" data-slean="hover-card">
	<strong>Drawer</strong>
	<p>The same dialog, attached to an edge of the viewport.</p>
	<a href="/primitives/dialog#drawer">Open the page</a>
</div>
```

The card may hold links; Tab moves from the link into its card.

### One card, many links

```svelte
<script lang="ts">
	import '@svelte-lean/styles/hover-card.css';

	const comments = [
		{ id: 1, text: 'Merged the release branch.' },
		{ id: 2, text: 'Fixed the flaky date test.' },
		{ id: 3, text: 'Updated the changelog.' }
	];
</script>

<!-- Every mention names the same card: one card in the page, however many rows. -->
<ul class="comments">
	{#each comments as comment (comment.id)}
		<li><a href="/people/ada" interestfor="ada-card">Ada Lovelace</a>: {comment.text}</li>
	{/each}
</ul>
<div id="ada-card" popover="hint" data-slean="hover-card">
	<strong>Ada Lovelace</strong>
	<p>Analyst · London</p>
	<a href="/people/ada/notes">Notes on the engine</a>
</div>

<style>
	.comments {
		margin: 0;
		padding-inline-start: 1.25rem;
	}
</style>
```

Every mention names the same card, which opens beside the link that showed it.

### Sides

```svelte
<script lang="ts">
	import '@svelte-lean/styles/hover-card.css';

	const sides = ['top', 'right', 'bottom', 'left'] as const;
</script>

<!-- data-slean-side on the card: bottom (default), top, left or right. -->
<div class="sides">
	{#each sides as side (side)}
		<a href="#sides" interestfor="side-{side}">{side}</a>
		<div id="side-{side}" popover="hint" data-slean="hover-card" data-slean-side={side}>
			The card on the {side} side.
		</div>
	{/each}
</div>

<style>
	.sides {
		display: flex;
		flex-wrap: wrap;
		gap: 2rem;
		justify-content: center;
		padding-block: 3rem;
	}
</style>
```

Top and bottom align with the link’s inline start and mirror in a right-to-left page; left and right are physical.

## Usage

Install `@svelte-lean/primitives` and `@svelte-lean/styles`. With `@svelte-lean/vite` the behavior registers itself for every `data-slean="hover-card"`; without it, import the register module once.

```bash
npm install @svelte-lean/primitives
```

`+layout.svelte or any client module`

```ts
import '@svelte-lean/primitives/hover-card/register';
```

`stylesheets`

```ts
import '@svelte-lean/styles/popover.css';
import '@svelte-lean/styles/hover-card.css';
```

Give the card `popover="hint"`, an `id` and `data-slean="hover-card"`, anywhere in the page, and the link `interestfor` naming the card. Several links may name one card. The link works on its own: the card only previews its destination, since touch users never see it.

`data-slean-side` and `data-slean-align` on the card place 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 | `<div id="…" popover="hint" data-slean="hover-card">` | – | yes | The card itself, anywhere in the document; may hold links and buttons. Options: data-slean-side (top, bottom, left, right), data-slean-align (start, center, end). |
| trigger | `<a href interestfor="<card id>">, <button interestfor> or any focusable element` | – | yes | A link works on its own: it goes where the card previews. Several triggers may name one card. Another element (a name in a table cell) needs tabindex="0"; the browser serves only links and buttons natively, the behavior serves the rest in every engine. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | onto the trigger | Shows the card |
| `Tab` | on the trigger, card open | Moves into the card |
| `Escape` | the card is open | Hides it; from inside, focus returns to the trigger |

## Styling

`hover-card.css` builds on `popover.css` (the surface, the anchor placement against the link, the side and align options, the open transition): it sizes the card, gives it a deeper shadow and aligns it with the link’s start when no `data-slean-align` is set. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-4` | `calc(var(--slean-spacing) * 4)` | max-inline-size, padding |
| `--slean-elevation-4` | `0 16px 40px oklch(0% 0 0 / 0.18), 0 2px 6px oklch(0% 0 0 / 0.08)` | box-shadow |
| `--slean-font-sans` | `Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif` | font-family |
| `--slean-typography-body2-size` | `0.875rem` | font-size |
| `--slean-typography-body2-weight` | `400` | font-weight |
| `--slean-typography-body2-line-height` | `1.45` | line-height |

State selectors the stylesheet targets, all from the platform or ARIA: `:first-child`, `:last-child`.

Variant attributes: `data-slean-align`; `data-slean-side` (`top`, `left`, `right`).

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 1986 B brotli · 2247 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `interestfor`, `popover="hint"`, `showPopover({ source })`, `position-area`
- **Shared listeners**: pointerover, pointerout, focusin, focusout, keydown
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `popover="hint" named by interestfor`

Tier 1 where interest invokers are missing, nothing where they ship: every handler returns at once when the browser implements `interestfor`. The hover card registers five handlers (pointerover, pointerout, focusin, focusout, keydown) with the shared router, which also brings the events of every `interestfor` link to it, and keeps for the page the open card, its link, one timer and the link Escape dismissed. A thousand cards keep one listener per type (`tests/hover-card.test.ts`).

Why this implementation

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 now has the whole pattern: a link names a `popover="hint"` card with `interestfor`, and Chrome and Edge show and hide it on hover and focus with their own delays (interest invokers). Svelte Lean runs no code there. Where interest invokers are missing (Firefox, Safari), the behavior does the same with shared listeners: 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

- with interest invokers (Chrome and Edge 142), showing and hiding the card for interestfor, its delays, keyboard focus and Escape
- the top layer; with popover="hint", closing other hints without closing an open menu
- the trigger as the invoker: the implicit anchor, and the card next in the focus order after it
- placement next to the trigger with anchor positioning

### Svelte Lean owns

- where interest invokers are missing: 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 on the popover surface, aligned with the trigger’s start

Accessibility

- The card is supplementary: what it shows is also where the link leads.
- Keyboard focus on the link shows the card; Tab moves into it because the link is its invoker, wherever the card sits in the DOM.
- Escape closes the card; from inside it, focus returns to the link and the card stays closed until focus leaves.
- Touch pointers are ignored: a tap follows the link.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Interest invokers (interestfor)` | Chrome and Edge 142; not in Firefox or Safari | The shared listeners show and hide the card with the platform’s default delays, and do so in every engine for a trigger that is not a link or a button |
| `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 and Firefox 155 | Falls back to manual; the behavior hides the card itself |
| `CSS anchor positioning` | Newly available since January 2026 (Chrome 125, Safari 26, Firefox 147) | The card keeps the platform default placement |

Without JavaScript

Where interest invokers ship, the card works with no script at all. Elsewhere it never shows; the link is a working link.

Server rendering and hydration

Render the markup as is; the card is hidden until shown.

Before the behavior loads, hovering shows nothing where interest invokers are missing. The first hover after hydration works on the server's markup.

Compatibility

Interest invokers are in Chrome and Edge 142; Firefox and Safari run the fallback. The Popover API is Baseline 2024; `popover="hint"` and the `source` option of `showPopover()`, which anchors the card to its link and places it after the link in the focus order, are in Chrome 133 and Firefox 155. Anchor positioning is Baseline since January 2026. The playground suite runs the card in Chromium (native) and Firefox (fallback).

Tests and source

- `packages/primitives/tests/hover-card.test.ts` Vitest, the fallback: delays, hover onto the card, one at a time, several triggers, touch, keyboard focus, Escape, the native path standing down, validation, 1000 cards
- `apps/playground/tests/primitives/widgets.spec.ts` Playwright: the tooltip, toast, tree, range slider, number field, file drop and hover card, with axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/hover-card/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/hover-card/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/hover-card/behavior.ts` the behavior definition
- `packages/primitives/src/hover-card/register.ts` the registration module
- `packages/styles/css/hover-card.css` the optional stylesheet
