Primitives Overlays
Hint
The short text of a tooltip written as one attribute, data-slean-hint, on any element. One bubble for the whole page, a manual popover with role="tooltip" in the top layer, shows the hint of the element the pointer rests on or keyboard focus reaches. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 2476 B brotli · 2737 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - 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
On this page
Note A Tooltip names its content in the markup through aria-describedby, so its text is announced even without JavaScript; a hint describes
its element only while it is shown. Use a tooltip for the few controls where that matters, and a
hint where many elements each carry a short text.
Example
<button type="button" data-slean="button" data-slean-hint="Saves the draft (Ctrl+S)">
Save
</button>
<button type="button" data-slean="button" data-variant="outline"
data-slean-hint="Copies a read-only link" data-slean-hint-side="bottom">
Share
</button>
<!-- An icon-only control keeps its own name: the hint is supplementary. -->
<button type="button" data-slean="button" data-variant="outline" data-icon-only
aria-label="Settings" data-slean-hint="Settings">
<svg …></svg>
</button><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';
let { draft }: { draft: { savedAt: string } } = $props();
</script>
<!-- A dynamic value is discovered too: the attribute name decides. -->
<button type="button" data-slean="button" data-slean-hint="Saved at {draft.savedAt}">
Save
</button>Why this implementation exists
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. The Tooltip primitive fixes that with a root, 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.
A hint keeps the cost of title: one attribute. The behavior registers five handlers with the shared router and draws one bubble for the whole page, 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 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
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 data-slean-hint attribute in the markup, with a static or a dynamic
value; 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/hint/register';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 (<button data-slean="button" data-slean-hint="Save">). When hints nest, the innermost hinted element wins.
A hint is supplementary. An icon-only control keeps its own accessible name (aria-label); a hint on an element that cannot receive focus is for the pointer only.
Keep the text short and plain. Links and buttons do not belong in a hint; 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. |
Runtime profile
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.
Accessibility contract
- While a hint is shown the element’s
aria-describedbynames the bubble (slean-hint), appended to the element’s own ids and restored when the hint hides; the bubble hasrole="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. A rich hint is copied into the bubble, so its text is the description; development validation warns about interactive content in it.
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 |
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
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 hydration
Before the behavior loads, resting the pointer shows nothing. The registration attaches no per-element state, so the first hover after hydration works.
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 | 0.5rem | max-inline-size, padding-inline, padding-block |
--slean-space-1 | 0.25rem | padding-block, margin-block-start |
--slean-radius-sm | 0.375rem | border-radius |
--slean-fg | var(--slean-neutral-12) | background |
--slean-bg | var(--slean-neutral-1) | color |
--slean-shadow-sm | 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 | 0.75rem | padding-inline |
State selectors the stylesheet targets, all from the platform or ARIA: [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.
<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>Compatibility notes
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.
Examples
Sides
data-slean-hint-side asks for a side; the bubble goes to the opposite side when the asked
one has no room in the viewport and the opposite one has more. The side is measured once, when the
hint shows: the bubble does not follow the element while the page scrolls.
Monospace, rich content and disabled controls
data-slean-hint-mono sets the bubble in the monospace face with tabular figures,
for ids and hashes. data-slean-hint-content names a hidden element whose children
the bubble shows; the element stays hidden in the page and the copy loses its ids. A hint on an aria-disabled="true" control explains why the action is unavailable; a native disabled button receives no focus, so it cannot show one from the keyboard.
Upgrade to raise the limit of three projects.
<!-- Options are attributes next to the hint. -->
<button data-slean-hint="Placed on the right" data-slean-hint-side="right">…</button>
<code data-slean-hint="inv_8f2a1c-44d0-9b3e" data-slean-hint-mono>inv_8f2a1c</code>
<!-- A rich hint: the bubble shows a copy of the hidden element's children. -->
<button data-slean-hint="Plan limits" data-slean-hint-content="limits-tip">Limits</button>
<div id="limits-tip" hidden>
<strong>Not available on this plan</strong>
<p>Upgrade to raise the limit of three projects.</p>
</div>
<!-- An aria-disabled control keeps its hint: it explains why the action is unavailable. -->
<button data-slean="button" aria-disabled="true" data-slean-hint="Available on the Team plan">
Invite
</button>Many hints
Every cell below carries its own data-slean-hint; the page has one bubble, created
on the first show, and the five shared listeners of the behavior. A cell is not focusable, so
its hint is for the pointer only: the full value it repeats must be in the page or reachable
another way.
| Name | Team | Key | Status |
|---|---|---|---|
| Amara Okafor | Engineering | usr_8f3a1f | Active |
| Jonas Lindqvist | Design | usr_8f4a22 | Active |
| Mei Tanaka | Operations | usr_8f5a25 | Away |
| Rafael Duarte | Growth | usr_8f6a28 | Invited |
| Selin Aydın | Engineering | usr_8f7a2b | Active |
| Noah Brenner | Design | usr_8f8a2e | Active |
| Priya Raman | Operations | usr_8f9a31 | Away |
| Elias Moreau | Growth | usr_8faa34 | Invited |
{#each invoices as invoice (invoice.id)}
<tr>
<!-- One attribute per cell; the page still has one bubble and five listeners. -->
<td data-slean-hint="{invoice.customer} ({invoice.email})">{invoice.customer}</td>
<td><code data-slean-hint={invoice.id} data-slean-hint-mono>{invoice.id.slice(0, 10)}</code></td>
<td data-slean-hint="Due {invoice.due}">{invoice.status}</td>
</tr>
{/each}Testing
packages/primitives/tests/hint.test.tsVitest: delays, neighbours, the bubble, aria-describedby, keyboard focus, Escape, rich and mono hints, the flip, 1000 hintspackages/primitives/tests/copy.test.tsVitest: the copied label swapped in while a copy is acknowledged, then restoredapps/playground/tests/primitives/hint.spec.tsPlaywright: hint and copy in Chrome, the top layer, keyboard focus, Escape, the flip, axepackages/styles/testsstatic checks of the stylesheet (layers, tokens, specificity, dark parity)
Source
packages/primitives/src/hint/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/hint/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/hint/behavior.tsthe behavior definition, showHint(), hideHint(), refreshHint(), contentRect()packages/primitives/src/hint/register.tsthe registration modulepackages/primitives/src/hint/validate.tsdevelopment validation messagesdocs/adr/0009-attribute-behaviors.mdwhy an attribute on any element can be the root of a behaviorpackages/styles/css/hint.cssthe optional stylesheet