Primitives Overlays
Tooltip
A short description of a control, shown on hover and on keyboard focus. The content is a native hint popover in the top layer, described by the trigger's aria-describedby; the behavior adds the delay, hover persistence and Escape. Tier 1.
- Tier
- 1 · Delegated micro-behavior
- Behavior JS
- 1297 B brotli · 1473 B gzip ·
packages/· methodprimitives/ artifacts/ size.json - Platform features
popover="hint",role="tooltip",aria-describedby,showPopover({ source }),position-area- Shared listeners
- pointerover, pointerout, focusin, focusout, keydown
- Per-instance listeners
- none
- Lazy state
- none
- Native base
popover="hint" + role="tooltip" + aria-describedby
On this page
Example
<span data-slean="tooltip">
<button type="button" data-slean="button" data-slean-part="trigger" aria-describedby="tooltip-save">
Save
</button>
<span id="tooltip-save" role="tooltip" popover="hint" data-slean-part="content">
Saves the draft (Ctrl+S)
</span>
</span>
<span data-slean="tooltip" data-slean-side="bottom">
<button type="button" data-slean="button" data-variant="outline" data-slean-part="trigger"
aria-describedby="tooltip-share">Share</button>
<span id="tooltip-share" role="tooltip" popover="hint" data-slean-part="content">
Copies a read-only link
</span>
</span><script lang="ts">
// With @svelte-lean/vite the register import is injected for the static data-slean="tooltip".
import '@svelte-lean/styles/button.css';
import '@svelte-lean/styles/tooltip.css';
</script>
<span data-slean="tooltip">
<button type="button" data-slean="button" data-slean-part="trigger" aria-describedby="tooltip-save">
Save
</button>
<span id="tooltip-save" role="tooltip" popover="hint" data-slean-part="content">
Saves the draft (Ctrl+S)
</span>
</span>Why this implementation exists
A tooltip has two hard parts: rendering above everything, and the timing. The first is the platform’s since the Popover API: the content is in the top layer, never clipped by an overflow container, and popover="hint" closes other hints without closing an open menu. What the platform has no trigger for is hover with a delay, keyboard focus and Escape.
Those need listeners, but not one per tooltip. The behavior registers one handler per event type with the shared router and keeps one pending timer and one open tooltip for the whole page, which is also how the delay is skipped when the pointer moves from one trigger to the next.
The browser owns
- the top layer: the content is never clipped by an overflow container or covered by a z-index
- popover="hint": opening a hint closes other hints, not open menus or popovers
- the accessible description of the trigger through aria-describedby
- placement next to the trigger with anchor positioning, where supported
Svelte Lean owns
- showing on hover after a delay, at once on keyboard focus, never on touch
- one pending timer and one open tooltip for the whole page; no delay between neighbours
- keeping the content open while the pointer is over it; Escape without moving focus
- tooltip.css: the bubble, the sides and 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 static data-slean="tooltip"; 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/tooltip/register';import '@svelte-lean/styles/tooltip.css';Wrap the trigger and the content in a root. The trigger names the content with aria-describedby, so its text is announced as the trigger’s description even without JavaScript.
Keep the content to text. A link or a button inside a tooltip cannot be reached from the keyboard; use a Popover for interactive content.
Anatomy
| Part | Element | data-slean-part | Required | Notes |
|---|---|---|---|---|
| root | <span data-slean="tooltip"> | – | yes | Options: data-slean-side (top, bottom, left, right), data-slean-delay in milliseconds. |
| trigger | <button aria-describedby="…"> | trigger | yes | Any focusable element; aria-describedby names the content. |
| content | <span role="tooltip" popover="hint" id="…"> | content | yes | Text only. Interactive content belongs in a popover. |
Runtime profile
The tooltip registers five handlers (pointerover, pointerout, focusin, focusout, keydown) with the shared router and keeps two variables for the page: the open tooltip and one timer. A thousand tooltips keep one listener per type (tests/tooltip.test.ts).
Accessibility contract
- The trigger’s
aria-describedbynames the content;role="tooltip"on the content. - Keyboard focus shows the tooltip at once; a mouse click that focuses the trigger does not.
- The content stays open while the pointer is over it and Escape hides it without moving focus (WCAG 1.4.13).
- Touch pointers are ignored: a long press is not a tooltip, and the description is still announced.
Keyboard
| Key | When | Result |
|---|---|---|
| Tab | onto the trigger | Shows the tooltip at once |
| Tab | away from the trigger | Hides it |
| Escape | a tooltip is open | Hides it; focus stays where it is |
Platform features
| Feature | Baseline | Outside the target |
|---|---|---|
Popover API | Baseline 2024 (Chrome 114, Safari 17, Firefox 125) | The content renders in place, hidden by the stylesheet until shown |
popover="hint" | Chrome 133; not Baseline | Falls back to manual; the behavior hides the content itself |
CSS anchor positioning | Chrome 125, Safari 26; not Baseline | The content keeps the platform default placement |
Without JavaScript
The content never shows. The trigger keeps its accessible description through aria-describedby, so screen reader users still hear the text.
Server rendering
Render the markup as is. A popover is not rendered until it is shown, so the content costs no layout.
Before hydration
Before the behavior loads, hovering shows nothing. The registration attaches no per-root state, so the first hover after hydration works.
Styling
tooltip.css draws the bubble from the inverted tokens, places it with position-area on the chosen side, anchors it through anchor-name scoped to the root, and fades it in. The tokens it reads:
| Token | Default (light) | Applies to |
|---|---|---|
--slean-space-2 | 0.5rem | margin-block, padding-inline, margin-inline |
--slean-space-1 | 0.25rem | padding-block |
--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-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 |
State selectors the stylesheet targets, all from the platform or ARIA: :popover-open.
Variant attributes: data-slean-side (bottom, left, right).
Compatibility notes
The Popover API is Baseline 2024. popover="hint" and the source option of showPopover() are Chromium-first; elsewhere the content falls back to a manual popover the behavior hides itself. Anchor positioning is in Chrome and Safari; without it the content is centered in the top layer.
Examples
Sides and delay
data-slean-side places the content on one of four sides with position-area, and position-try-fallbacks flips it when there is no
room. data-slean-delay sets the hover delay of one root.
<!-- Placement and delay are attributes on the root. -->
<span data-slean="tooltip" data-slean-side="right" data-slean-delay="300">…</span>Testing
packages/primitives/tests/tooltip.test.tsVitest: delays, hover onto the content, neighbours, touch, Escape, 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/tooltip/contract.mdthe contract in prose: semantics, keyboard, focus, ARIA, SSR, JS-disabled behaviorpackages/primitives/src/tooltip/contract.tstyped constants: name, tier, base, parts, options, eventspackages/primitives/src/tooltip/behavior.tsthe behavior definitionpackages/primitives/src/tooltip/register.tsthe registration modulepackages/styles/css/tooltip.cssthe optional stylesheet