sveltelean Primitives
Versionv0.8.0 GitHub

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

Three hinted buttons
<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>
Rest the pointer on a button, then move to the next one: it shows without the delay. Tab onto a button to show its hint at once; Escape hides it.

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

PartElementdata-slean-partRequiredNotes
rootany element with data-slean-hint="…"–yesOptions: 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>–noNamed 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">–noCreated 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-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. A rich hint is copied into the bubble, so its text is the description; development validation warns about interactive content in it.

Keyboard

KeyWhenResult
Tabonto a hinted elementShows its hint at once
Tabaway from itHides the hint
Escapea hint is shownHides it; focus stays where it is. The key is consumed only while a hint is shown

Platform features

FeatureBaselineOutside the target
Popover APIBaseline 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-visibleBaseline widely available (Chrome 86, Firefox 85, Safari 15.4)Where the selector throws, keyboard and pointer focus are treated alike
Range.getBoundingClientRect()Baseline widely availableWhere it measures nothing, the element’s own box is used
Pointer eventsBaseline widely availableTouch 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:

TokenDefault (light)Applies to
--slean-space-20.5remmax-inline-size, padding-inline, padding-block
--slean-space-10.25rempadding-block, margin-block-start
--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-font-sansInter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-seriffont-family
--slean-text-xs0.75remfont-size
--slean-leading1.5line-height
--slean-duration-fast100mstransition
--slean-easecubic-bezier(0.2, 0, 0, 1)transition
--slean-font-monoui-monospace, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospacefont-family
--slean-space-30.75rempadding-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.

save-button.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>

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.

options
<!-- 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.

NameTeamKeyStatus
Amara OkaforEngineeringusr_8f3a1fActive
Jonas LindqvistDesignusr_8f4a22Active
Mei TanakaOperationsusr_8f5a25Away
Rafael DuarteGrowthusr_8f6a28Invited
Selin AydınEngineeringusr_8f7a2bActive
Noah BrennerDesignusr_8f8a2eActive
Priya RamanOperationsusr_8f9a31Away
Elias MoreauGrowthusr_8faa34Invited
members.svelte
{#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

Source