# Tooltip

> The tooltip is one attribute, data-slean-hint, on any element: one bubble for the page, shown on hover and keyboard focus, with no per-element markup or state.

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

A short text shown on hover and keyboard focus, written as one attribute, data-slean-hint, on any element.

```svelte
<script lang="ts">
	// With @svelte-lean/vite the register import is injected for every data-slean-hint attribute.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/hint.css';
</script>

<div class="row">
	<button type="button" data-slean="button" data-slean-hint="Saves the draft (Ctrl+S)">
		Save
	</button>
	<button
		type="button"
		data-slean="button"
		data-variant="outlined"
		data-color="neutral"
		data-slean-hint="Copies a read-only link"
	>
		Share
	</button>
	<!-- An icon-only control keeps its own name: the hint is supplementary. -->
	<button
		type="button"
		data-slean="button"
		data-variant="outlined"
		data-color="neutral"
		data-icon-only
		aria-label="Settings"
		data-slean-hint="Settings"
	>
		<svg
			viewBox="0 0 24 24"
			fill="none"
			stroke="currentColor"
			stroke-width="2"
			stroke-linecap="round"
			stroke-linejoin="round"
			aria-hidden="true"
		>
			<circle cx="12" cy="12" r="3" />
			<path
				d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1a1.7 1.7 0 0 0 1.5-1.1 1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1Z"
			/>
		</svg>
	</button>
</div>

<style>
	.row {
		display: flex;
		gap: 0.75rem;
	}
</style>
```

Rest the pointer on a button, then move to the next: it shows without the delay. Tab onto a button to show its hint at once; Escape hides it.

## Examples

### Sides

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

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

<!-- The bubble flips to the opposite side when the asked one has no room. -->
<div class="sides">
	{#each sides as side (side)}
		<button
			type="button"
			data-slean="button"
			data-variant="outlined"
			data-color="neutral"
			data-slean-hint="Placed at the {side}"
			data-slean-hint-side={side}
		>
			{side}
		</button>
	{/each}
</div>

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

The side is measured once, when the hint shows; the bubble does not follow the element while the page scrolls.

### Many hints

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

	const members = [
		{ name: 'Ada Lovelace', email: 'ada@example.com', team: 'Engineering', key: 'usr_8f2a1c-44d0-9b3e' },
		{ name: 'Grace Hopper', email: 'grace@example.com', team: 'Operations', key: 'usr_3c91e0-7a12-55f1' },
		{ name: 'Alan Turing', email: 'alan@example.com', team: 'Research', key: 'usr_b07d42-19c8-e6a0' },
		{ name: 'Mary Somerville', email: 'mary@example.com', team: 'Design', key: 'usr_5ea3f9-c204-8d17' }
	];
</script>

<!-- One attribute per cell; the page still has one bubble and five listeners. -->
<table>
	<thead>
		<tr><th>Name</th><th>Team</th><th>Key</th></tr>
	</thead>
	<tbody>
		{#each members as member (member.key)}
			<tr>
				<td data-slean-hint={member.email}>{member.name}</td>
				<td data-slean-hint="{member.team} team">{member.team}</td>
				<td><code data-slean-hint={member.key} data-slean-hint-mono>{member.key.slice(0, 10)}</code></td>
			</tr>
		{/each}
	</tbody>
</table>

<style>
	table {
		inline-size: 100%;
		border-collapse: collapse;
	}

	th,
	td {
		padding: 0.5rem 0.75rem;
		border-block-end: 1px solid var(--slean-border);
		text-align: start;
	}
</style>
```

data-slean-hint-mono sets ids in the monospace face. A cell is not focusable, so its hint is for the pointer only.

### Rich content

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

<!-- The bubble shows a copy of the hidden element's children. -->
<button
	type="button"
	data-slean="button"
	data-variant="outlined"
	data-color="neutral"
	data-slean-hint="Plan limits"
	data-slean-hint-content="hint-limits"
>
	Limits
</button>
<div id="hint-limits" hidden>
	<strong>Three projects on this plan</strong>
	<p>The Team plan raises the limit to fifty.</p>
</div>
```

data-slean-hint-content names a hidden element; the bubble shows a copy of its children.

### Disabled control

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

<!-- aria-disabled keeps the button focusable, so its hint is reachable from the keyboard. -->
<button
	type="button"
	data-slean="button"
	aria-disabled="true"
	data-slean-hint="Available on the Team plan"
>
	Invite people
</button>
```

A native disabled button receives no focus, so it cannot show a hint from the keyboard.

### With a copy button

```svelte
<script lang="ts">
	// data-slean-hint-copied comes from the copy primitive: the hint reads it while copied.
	import '@svelte-lean/styles/button.css';
	import '@svelte-lean/styles/copy.css';
	import '@svelte-lean/styles/hint.css';
</script>

<button
	type="button"
	data-slean="button"
	data-variant="outlined"
	data-color="neutral"
	data-slean-copy="yarn add @svelte-lean/primitives"
	data-slean-hint="Copy the command"
	data-slean-hint-copied="Copied"
>
	<code>yarn add @svelte-lean/primitives</code>
</button>
```

With the copy primitive, the hint reads data-slean-hint-copied for a moment after a copy.

## Usage

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

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

`+layout.svelte or any client module`

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

`stylesheets`

```ts
import '@svelte-lean/styles/hint.css';
```

Put `data-slean-hint="Text"` on the element the hint describes: a button, a link, a table cell, the root of another primitive. When hints nest, the innermost hinted element wins. An icon-only control keeps its own accessible name (`aria-label`).

Keep the text short and plain. `data-slean-hint-side` picks the side, `data-slean-hint-content` names a hidden element whose children make a rich tooltip, `data-slean-hint-mono` sets ids in the monospace face. Links and buttons do not belong in a tooltip; use a Popover or a Hover card for interactive content.

## Anatomy

| Part | Element | data-slean-part | Required | Notes |
| --- | --- | --- | --- | --- |
| root | `any element with data-slean-hint="…"` | – | yes | Options: data-slean-hint-side (top, right, bottom, left), data-slean-hint-content (an element id), data-slean-hint-mono, data-slean-hint-copied. The innermost hinted element wins. |
| rich content | `<div id="…" hidden>` | – | no | Named by data-slean-hint-content. Stays hidden in the page; the bubble shows a copy of its children without ids. Text only. |
| bubble | `<div id="slean-hint" data-slean="hint" role="tooltip" popover="manual">` | – | no | Created by the behavior on the first show, once for the page, appended to \<body\>. Authors never write it; data-side names the side it was placed on. |

## Keyboard

| Key | When | Result |
| --- | --- | --- |
| `Tab` | onto a hinted element | Shows its hint at once |
| `Tab` | away from it | Hides the hint |
| `Escape` | a hint is shown | Hides it; focus stays where it is. The key is consumed only while a hint is shown |

## Styling

`hint.css` draws the bubble from the inverted tokens, fades it in from the side it was placed on (`data-side`), and switches to the monospace face or to start-aligned block content from the flags the behavior copies onto the bubble. The bubble is placed with inline `left` and `top`, allowed under a strict CSP. The tokens it reads:

| Token | Default (light) | Applies to |
| --- | --- | --- |
| `--slean-space-2` | `calc(var(--slean-spacing) * 2)` | max-inline-size, padding-inline, padding-block |
| `--slean-space-1` | `calc(var(--slean-spacing) * 1)` | padding-block, margin-block-start |
| `--slean-radius-sm` | `calc(var(--slean-radius) * 0.6)` | border-radius |
| `--slean-fg` | `var(--slean-neutral-12)` | background |
| `--slean-bg` | `var(--slean-neutral-1)` | color |
| `--slean-elevation-1` | `0 1px 2px 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-text-xs` | `0.75rem` | font-size |
| `--slean-leading` | `1.5` | line-height |
| `--slean-duration-fast` | `100ms` | transition |
| `--slean-ease` | `cubic-bezier(0.2, 0, 0, 1)` | transition |
| `--slean-font-mono` | `ui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace` | font-family |
| `--slean-space-3` | `calc(var(--slean-spacing) * 3)` | padding-inline |

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

Variant attributes: `data-side` (`top`, `bottom`, `left`, `right`); `data-slean-hint-mono`; `data-slean-hint-content`.

## Controlled integration

The behavior reads the attributes when a hint shows. When the application changes the hint of an element while its hint is on screen, `refreshHint(element)` redraws it; for any other element it does nothing. `showHint(element)` and `hideHint()` show and hide a hint from code.

`save-button.svelte`

```svelte
<script lang="ts">
	import { refreshHint } from '@svelte-lean/primitives/hint';

	let button: HTMLButtonElement;
	let saving = $state(false);

	$effect(() => {
		// The attribute is written by Svelte; a hint that is on screen is redrawn from it.
		void saving;
		refreshHint(button);
	});
</script>

<button bind:this={button} type="button" data-slean-hint={saving ? 'Saving…' : 'Save the draft'}>
	Save
</button>
```

## Under the hood

Runtime and size

- **Tier**: 1 · Delegated micro-behavior
- **Behavior JS**: 2687 B brotli · 2986 B gzip · `packages/primitives/artifacts/size.json` · [method](https://svelte-lean.vercel.app/performance/methodology.md)
- **Platform features**: `popover="manual"`, `role="tooltip"`, `aria-describedby`, `:focus-visible`, `Range.getBoundingClientRect()`
- **Shared listeners**: pointerover, pointerout, pointerdown, focusin, focusout, keydown
- **Per-instance listeners**: none
- **Lazy state**: none
- **Native base**: `role="tooltip" + popover="manual" + aria-describedby`

Tier 1. The hint registers five handlers (pointerover, pointerout, focusin, focusout, keydown) with the shared router and keeps, for the page, one bubble, the shown element, the pending element and one timer. Layout is read once per show: the element's box, the box of its text through a `Range`, the bubble and the viewport. While a hint is shown one timer checks that its element is still in the document; nothing runs while no hint is shown.

Why this implementation

The `title` attribute is one attribute per element, but the browser shows it late, never on keyboard focus, in a box the page cannot style, and not at all on touch. Component libraries fix that with a trigger and a content element per instance, which is the right cost for a few controls and the wrong one for a table where every cell has something to say.

Svelte Lean has one tooltip, and it keeps the cost of `title`: one attribute, `data-slean-hint` (ADR 0011 §1.1). One bubble for the whole page, a manual popover with `role="tooltip"` in the top layer, is filled with the text of the element it is shown for. Nothing is created per element, so a table with a thousand hinted cells costs attributes only (`tests/hint.test.ts` keeps one listener per event type for a thousand hints). The bubble is a manual popover rather than a `hint` one, so a tooltip inside an open hover card does not close the card.

### The browser owns

- the top layer: the bubble is above an open dialog or popover and never clipped by an overflow container
- the accessible description of the hinted element through aria-describedby
- keyboard focus and :focus-visible, which tell a Tab from a click

### Svelte Lean owns

- one bubble for the whole page, created on the first show; no markup per hinted element
- showing on a pointer rest, at once on keyboard focus, never on touch; no delay between neighbours
- placing the bubble beside the element’s text, flipped to the opposite side when there is no room
- keeping the bubble while the pointer is over it; Escape without moving focus
- hint.css: the bubble, the monospace and rich variants, the entry transition; development validation

Accessibility

- While a hint is shown the element’s `aria-describedby` names the bubble (`slean-hint`), appended to the element’s own ids and restored when the hint hides; the bubble has `role="tooltip"`.
- Keyboard focus (`:focus-visible`) shows the hint at once; a mouse click that focuses the element does not. The bubble never receives focus.
- The bubble stays while the pointer is over it, and Escape hides it without moving focus or the pointer (WCAG 1.4.13); the key is consumed only while a hint is shown.
- Touch pointers are ignored, and a hint on an element that cannot receive focus is for the pointer only. A rich hint is copied into the bubble, so its text is the description; development validation warns about interactive content in it.

Platform features

| Feature | Baseline | Outside the target |
| --- | --- | --- |
| `Popover API` | Baseline 2024 (Chrome 114, Safari 17, Firefox 125) | The stylesheet’s fixed position and z-index keep the bubble above the page, not above a modal dialog |
| `:focus-visible` | Baseline widely available (Chrome 86, Firefox 85, Safari 15.4) | Where the selector throws, keyboard and pointer focus are treated alike |
| `Range.getBoundingClientRect()` | Baseline widely available | Where it measures nothing, the element’s own box is used |
| `Pointer events` | Baseline widely available | Touch pointers are ignored |

Without JavaScript

No bubble shows, and the hint text lives only in an attribute that assistive technology does not read. Give icon-only controls an aria-label and keep information that must be available in the page.

Server rendering and hydration

Render the attributes as they are. Nothing is created on the server or at import; the bubble is appended to the body in the browser on the first show.

Before the behavior loads, resting the pointer shows nothing. The registration attaches no per-element state, so the first hover after hydration works.

Compatibility

The Popover API is Baseline 2024: the bubble is in the top layer, above an open modal dialog. Without it the stylesheet's fixed position and z-index keep the bubble above the page but not above a modal dialog. Placement needs no anchor positioning: the behavior measures the element once per show. `left` and `right` are physical sides in both directions.

Tests and source

- `packages/primitives/tests/hint.test.ts` Vitest: delays, neighbours, the bubble, aria-describedby, keyboard focus, Escape, rich and mono hints, the flip, 1000 hints
- `packages/primitives/tests/copy.test.ts` Vitest: the copied label swapped in while a copy is acknowledged, then restored
- `apps/playground/tests/primitives/hint.spec.ts` Playwright: hint and copy in Chrome, the top layer, keyboard focus, Escape, the flip, axe
- `packages/styles/tests` static checks of the stylesheet (layers, tokens, specificity, dark parity)

- `packages/primitives/src/hint/contract.md` the contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behavior
- `packages/primitives/src/hint/contract.ts` typed constants: name, tier, base, parts, options, events
- `packages/primitives/src/hint/behavior.ts` the behavior definition, showHint(), hideHint(), refreshHint(), contentRect()
- `packages/primitives/src/hint/register.ts` the registration module
- `packages/primitives/src/hint/validate.ts` development validation messages
- `docs/adr/0009-attribute-behaviors.md` why an attribute on any element can be the root of a behavior
- `packages/styles/css/hint.css` the optional stylesheet
