sveltelean Primitives
Versionv0.2.0 GitHub

Example

Two tooltips
Saves the draft (Ctrl+S) Copies a read-only link
<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>
Hover a button and wait, then move to the other one: the second shows without the delay. Tab onto a button to show its tooltip at once; Escape hides it.

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/primitives
+layout.svelte or any client module
import '@svelte-lean/primitives/tooltip/register';
stylesheets
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

PartElementdata-slean-partRequiredNotes
root<span data-slean="tooltip">–yesOptions: data-slean-side (top, bottom, left, right), data-slean-delay in milliseconds.
trigger<button aria-describedby="…">triggeryesAny focusable element; aria-describedby names the content.
content<span role="tooltip" popover="hint" id="…">contentyesText 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-describedby names 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

KeyWhenResult
Tabonto the triggerShows the tooltip at once
Tabaway from the triggerHides it
Escapea tooltip is openHides it; focus stays where it is

Platform features

FeatureBaselineOutside the target
Popover APIBaseline 2024 (Chrome 114, Safari 17, Firefox 125)The content renders in place, hidden by the stylesheet until shown
popover="hint"Chrome 133; not BaselineFalls back to manual; the behavior hides the content itself
CSS anchor positioningChrome 125, Safari 26; not BaselineThe 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:

TokenDefault (light)Applies to
--slean-space-20.5remmargin-block, padding-inline, margin-inline
--slean-space-10.25rempadding-block
--slean-radius-sm0.375remborder-radius
--slean-fgvar(--slean-neutral-12)background
--slean-bgvar(--slean-neutral-1)color
--slean-shadow-sm0 1px 2px oklch(0% 0 0 / 0.08)box-shadow
--slean-text-xs0.75remfont-size
--slean-leading1.5line-height
--slean-duration-fast100mstransition
--slean-easecubic-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.

Placed at the top Placed at the right Placed at the bottom Placed at the left
options
<!-- Placement and delay are attributes on the root. -->
<span data-slean="tooltip" data-slean-side="right" data-slean-delay="300">…</span>

Testing

Source