···11+<script module lang="ts">
22+ import { defineMeta } from "@storybook/addon-svelte-csf";
33+ import Plus from "$icon/plus";
44+ import Button from "./Button.svelte";
55+ import Tooltip from "./Tooltip.svelte";
66+77+ const { Story } = defineMeta({
88+ title: "UI/Tooltip",
99+ component: Tooltip,
1010+ tags: ["autodocs"]
1111+ });
1212+</script>
1313+1414+<!-- Tooltip's `children` is the trigger it wraps, so every story renders via `asChild`
1515+ rather than through the args table. Hover or tab to the trigger in the canvas — `open`
1616+ is internal state unless you bind it. The stories pad generously because the bubble is
1717+ a popover in the top layer and would otherwise sit over the canvas edge. -->
1818+1919+{#snippet richContent()}
2020+ <span class="font-medium">Sign the commit</span>
2121+ <span class="block text-foreground-on-emphasis/70">
2222+ Requires a key registered on your account.
2323+ </span>
2424+{/snippet}
2525+2626+<Story name="Default" asChild>
2727+ <div class="flex justify-center p-16">
2828+ <Tooltip content="Add to library">
2929+ <Button>Hover me</Button>
3030+ </Tooltip>
3131+ </div>
3232+</Story>
3333+3434+<!-- `side` picks the preferred edge; the bubble still flips to the opposite one when the
3535+ preferred side would overflow the viewport. -->
3636+<Story name="Sides" asChild>
3737+ <div class="flex justify-center gap-2 p-16">
3838+ {#each ["left", "top", "bottom", "right"] as const as side (side)}
3939+ <Tooltip {side} content={side}>
4040+ <Button>{side}</Button>
4141+ </Tooltip>
4242+ {/each}
4343+ </div>
4444+</Story>
4545+4646+<!-- the canonical use: an icon-only button whose aria-label and tooltip say the same thing -->
4747+<Story name="Icon button" asChild>
4848+ <div class="flex justify-center p-16">
4949+ <Tooltip content="New issue">
5050+ <Button icon={Plus} aria-label="New issue" class="px-2" />
5151+ </Tooltip>
5252+ </div>
5353+</Story>
5454+5555+<!-- `content` also takes a snippet when a single string is not enough -->
5656+<Story name="Rich content" asChild>
5757+ <div class="flex justify-center p-16">
5858+ <Tooltip content={richContent}>
5959+ <Button>Signed commits</Button>
6060+ </Tooltip>
6161+ </div>
6262+</Story>
6363+6464+<!-- `delay={0}` opens on hover with no wait. Note that once any tooltip has been shown,
6565+ moving to a neighbouring trigger opens instantly regardless of delay. -->
6666+<Story name="No delay" asChild>
6767+ <div class="flex justify-center gap-2 p-16">
6868+ <Tooltip content="Opens immediately" delay={0}>
6969+ <Button>No delay</Button>
7070+ </Tooltip>
7171+ <Tooltip content="Waits 700ms" delay={700}>
7272+ <Button>Slow</Button>
7373+ </Tooltip>
7474+ </div>
7575+</Story>
7676+7777+<!-- the arrow points at whichever edge the bubble actually landed on, so it stays correct
7878+ even when the preferred side had to flip. `arrow={false}` drops it. -->
7979+<Story name="No arrow" asChild>
8080+ <div class="flex justify-center p-16">
8181+ <Tooltip content="No pointer" arrow={false}>
8282+ <Button>No arrow</Button>
8383+ </Tooltip>
8484+ </div>
8585+</Story>
8686+8787+<!-- `disabled` skips the tooltip without changing the markup around the trigger -->
8888+<Story name="Disabled" asChild>
8989+ <div class="flex justify-center p-16">
9090+ <Tooltip content="You will not see this" disabled>
9191+ <Button>No tooltip</Button>
9292+ </Tooltip>
9393+ </div>
9494+</Story>
···11+<script module lang="ts">
22+ import { tv, type VariantProps } from "tailwind-variants";
33+44+ export const tooltip = tv({
55+ slots: {
66+ // the wrapper is the anchor: it has to hug its child so the tooltip lines up with
77+ // the trigger and not with a full-width box around it
88+ wrapper: "inline-flex",
99+ // overflow-visible undoes the UA stylesheet's `overflow: auto` on [popover], which
1010+ // would otherwise clip the arrow off at the bubble's padding box
1111+ bubble:
1212+ "tooltip-bubble fixed z-50 m-0 w-max max-w-64 overflow-visible rounded-sm bg-background-emphasis px-2 pt-1.5 pb-1 typography-paragraph-small text-foreground-inverted shadow-regular"
1313+ },
1414+ variants: {
1515+ side: {
1616+ top: {},
1717+ right: {},
1818+ bottom: {},
1919+ left: {}
2020+ }
2121+ },
2222+ defaultVariants: {
2323+ side: "top"
2424+ }
2525+ });
2626+2727+ export type TooltipVariants = VariantProps<typeof tooltip>;
2828+2929+ // hovering a second trigger while one tooltip is up should swap them, not stack them.
3030+ // `popover="hint"` would do this natively but is not widely supported yet, so keep a
3131+ // module-level singleton the way Dropdown keeps its groups.
3232+ let openTooltip: (() => void) | undefined;
3333+3434+ // once a tooltip has been shown, moving to a neighbouring trigger inside this window
3535+ // opens instantly — a toolbar of icon buttons feels broken if every hop re-waits.
3636+ const SKIP_DELAY = 300;
3737+ let lastHiddenAt = 0;
3838+</script>
3939+4040+<script lang="ts">
4141+ import type { Snippet } from "svelte";
4242+ import { onMount } from "svelte";
4343+4444+ interface Props {
4545+ /** Tooltip body: a plain string for the common case, a snippet for rich content. */
4646+ content: string | Snippet;
4747+ side?: TooltipVariants["side"];
4848+ /** ms to wait before opening on hover. Keyboard focus always opens immediately. */
4949+ delay?: number;
5050+ /** Skip the tooltip entirely — handy when the label is only useful in some states. */
5151+ disabled?: boolean;
5252+ /** Two-way: set it to drive the tooltip yourself, e.g. a transient "Copied!". */
5353+ open?: boolean;
5454+ /** Draw the little pointer at the trigger. */
5555+ arrow?: boolean;
5656+ id?: string;
5757+ class?: string;
5858+ bubbleClass?: string;
5959+ children: Snippet;
6060+ }
6161+6262+ let {
6363+ content,
6464+ side = "top",
6565+ delay = 300,
6666+ disabled = false,
6767+ open = $bindable(false),
6868+ arrow = true,
6969+ id,
7070+ class: className,
7171+ bubbleClass,
7272+ children
7373+ }: Props = $props();
7474+7575+ const classes = $derived(tooltip({ side }));
7676+ const fallbackId = $props.id();
7777+ const bubbleId = $derived(id ?? fallbackId);
7878+ // anchor-name has to be a custom ident and unique per instance, so it cannot live in the
7979+ // stylesheet — derive it from the same id the bubble is labelled with
8080+ const anchorName = $derived(`--tooltip-${bubbleId.replace(/[^a-zA-Z0-9_-]/g, "-")}`);
8181+8282+ let wrapperEl = $state<HTMLElement>();
8383+ let bubbleEl = $state<HTMLElement>();
8484+ let timer: ReturnType<typeof setTimeout> | undefined;
8585+8686+ // The arrow cannot be driven off `side`: position-try flips the bubble to the opposite
8787+ // edge without touching any attribute, so a preference of "top" can render below the
8888+ // trigger and the arrow would point away from it. Both the edge the bubble actually
8989+ // landed on and the arrow's offset along that edge are therefore measured from geometry.
9090+ // undefined until the first measurement, so `side` stays the honest answer until then
9191+ let measuredSide = $state<TooltipVariants["side"] | undefined>();
9292+ const resolvedSide = $derived(measuredSide ?? side);
9393+ let arrowOffset = $state(0);
9494+9595+ // keeps the arrow clear of the bubble's rounded corners when the trigger sits near one end
9696+ const ARROW_INSET = 8;
9797+9898+ const measureArrow = () => {
9999+ if (!arrow || !bubbleEl || !wrapperEl) return;
100100+ const bubble = bubbleEl.getBoundingClientRect();
101101+ const trigger = wrapperEl.getBoundingClientRect();
102102+ if (bubble.width === 0) return;
103103+104104+ // only the axis the preferred side lives on can flip, so one comparison settles it
105105+ const vertical = side === "top" || side === "bottom";
106106+ measuredSide = vertical
107107+ ? bubble.top >= trigger.bottom - 1
108108+ ? "bottom"
109109+ : "top"
110110+ : bubble.left >= trigger.right - 1
111111+ ? "right"
112112+ : "left";
113113+114114+ // centre the arrow on the trigger rather than on the bubble: near a viewport edge the
115115+ // bubble slides sideways to stay visible while the trigger stays put
116116+ const along = vertical
117117+ ? trigger.left + trigger.width / 2 - bubble.left
118118+ : trigger.top + trigger.height / 2 - bubble.top;
119119+ const extent = vertical ? bubble.width : bubble.height;
120120+ arrowOffset = Math.min(
121121+ Math.max(along, ARROW_INSET),
122122+ Math.max(extent - ARROW_INSET, ARROW_INSET)
123123+ );
124124+ };
125125+126126+ const cancel = () => {
127127+ clearTimeout(timer);
128128+ timer = undefined;
129129+ };
130130+131131+ const show = () => {
132132+ if (disabled) return;
133133+ cancel();
134134+ if (openTooltip && openTooltip !== hide) openTooltip();
135135+ openTooltip = hide;
136136+ bubbleEl?.showPopover();
137137+ };
138138+139139+ const hide = () => {
140140+ cancel();
141141+ if (openTooltip === hide) openTooltip = undefined;
142142+ bubbleEl?.hidePopover();
143143+ };
144144+145145+ const scheduleShow = () => {
146146+ if (disabled || open) return;
147147+ const instant = delay === 0 || Date.now() - lastHiddenAt < SKIP_DELAY;
148148+ if (instant) {
149149+ show();
150150+ return;
151151+ }
152152+ cancel();
153153+ timer = setTimeout(show, delay);
154154+ };
155155+156156+ const onPointerEnter = (event: PointerEvent) => {
157157+ // touch has no hover: the tap would open the tooltip and swallow the visual feedback
158158+ // of whatever was actually tapped
159159+ if (event.pointerType === "touch") return;
160160+ scheduleShow();
161161+ };
162162+163163+ const onFocusIn = () => {
164164+ // no delay on focus: aria-describedby only resolves while the bubble is rendered, so
165165+ // it has to be up by the time a keyboard user lands on the trigger
166166+ if (!disabled) show();
167167+ };
168168+169169+ const onToggle = (event: Event) => {
170170+ const { newState } = event as ToggleEvent;
171171+ open = newState === "open";
172172+ if (!open) lastHiddenAt = Date.now();
173173+ };
174174+175175+ $effect(() => {
176176+ if (!bubbleEl) return;
177177+ // keeps a consumer-driven `open` in sync with the popover; both calls are no-ops when
178178+ // the state already matches, so this cannot ping-pong with onToggle
179179+ if (open) bubbleEl.showPopover();
180180+ else bubbleEl.hidePopover();
181181+ });
182182+183183+ $effect(() => {
184184+ if (disabled) hide();
185185+ });
186186+187187+ // declared after the sync effect above so the popover is already rendered by the time this
188188+ // one measures — effects in the same flush run in declaration order
189189+ $effect(() => {
190190+ if (!open) return;
191191+ measureArrow();
192192+193193+ const onKeydown = (event: KeyboardEvent) => {
194194+ if (event.key === "Escape") hide();
195195+ };
196196+ // the bubble tracks its anchor on scroll for free, but a flip mid-scroll would leave the
197197+ // arrow on the wrong edge, so re-measure whenever the geometry can have changed
198198+ document.addEventListener("keydown", onKeydown);
199199+ document.addEventListener("scroll", measureArrow, { capture: true, passive: true });
200200+ window.addEventListener("resize", measureArrow);
201201+ return () => {
202202+ document.removeEventListener("keydown", onKeydown);
203203+ document.removeEventListener("scroll", measureArrow, { capture: true });
204204+ window.removeEventListener("resize", measureArrow);
205205+ };
206206+ });
207207+208208+ onMount(() => {
209209+ const wrapper = wrapperEl;
210210+ if (!wrapper) return;
211211+212212+ // the handlers go on the wrapper rather than in the markup because a span carrying
213213+ // pointer handlers has no honest ARIA role to give the a11y lint — the interactive
214214+ // element is the consumer's child. pointerenter/leave don't bubble but do fire on the
215215+ // wrapper's own boundary, which encloses the trigger, so this is equivalent.
216216+ wrapper.addEventListener("pointerenter", onPointerEnter);
217217+ wrapper.addEventListener("pointerleave", hide);
218218+ wrapper.addEventListener("focusin", onFocusIn);
219219+ wrapper.addEventListener("focusout", hide);
220220+ // a click means the user acted; the label has served its purpose and is now in the way
221221+ wrapper.addEventListener("click", hide);
222222+223223+ // point aria-describedby at whatever interactive element is inside rather than at the
224224+ // wrapper span, which AT ignores
225225+ const target =
226226+ wrapper.querySelector<HTMLElement>(
227227+ 'button, a[href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
228228+ ) ?? wrapper;
229229+ const previous = target.getAttribute("aria-describedby");
230230+ target.setAttribute("aria-describedby", previous ? `${previous} ${bubbleId}` : bubbleId);
231231+232232+ return () => {
233233+ wrapper.removeEventListener("pointerenter", onPointerEnter);
234234+ wrapper.removeEventListener("pointerleave", hide);
235235+ wrapper.removeEventListener("focusin", onFocusIn);
236236+ wrapper.removeEventListener("focusout", hide);
237237+ wrapper.removeEventListener("click", hide);
238238+ if (previous) target.setAttribute("aria-describedby", previous);
239239+ else target.removeAttribute("aria-describedby");
240240+ cancel();
241241+ if (openTooltip === hide) openTooltip = undefined;
242242+ };
243243+ });
244244+</script>
245245+246246+<span
247247+ bind:this={wrapperEl}
248248+ class={classes.wrapper({ class: className })}
249249+ style="anchor-name: {anchorName}"
250250+>
251251+ {@render children()}
252252+</span>
253253+254254+<div
255255+ bind:this={bubbleEl}
256256+ id={bubbleId}
257257+ role="tooltip"
258258+ popover="manual"
259259+ data-side={side}
260260+ data-resolved-side={resolvedSide}
261261+ data-arrow={arrow || undefined}
262262+ style="position-anchor: {anchorName}; --tooltip-arrow: {arrowOffset}px"
263263+ class={classes.bubble({ class: bubbleClass })}
264264+ ontoggle={onToggle}
265265+>
266266+ {#if typeof content === "string"}
267267+ {content}
268268+ {:else}
269269+ {@render content()}
270270+ {/if}
271271+</div>
272272+273273+<style>
274274+ /* anchor positioning went Baseline in early 2026; without it the popover falls back to
275275+ the UA's centred position, same trade-off Dropdown already takes */
276276+ @supports (position-area: bottom) and (position-try: flip-block) {
277277+ .tooltip-bubble {
278278+ /* the gap between trigger and bubble — margin, because position-area owns inset */
279279+ margin: 0.25rem;
280280+ position-try: flip-block, flip-inline;
281281+ }
282282+283283+ .tooltip-bubble[data-side="top"] {
284284+ position-area: block-start;
285285+ }
286286+287287+ .tooltip-bubble[data-side="bottom"] {
288288+ position-area: block-end;
289289+ }
290290+291291+ .tooltip-bubble[data-side="left"] {
292292+ position-area: inline-start;
293293+ }
294294+295295+ .tooltip-bubble[data-side="right"] {
296296+ position-area: inline-end;
297297+ }
298298+ }
299299+300300+ /* the arrow exactly fills the 0.25rem gap, so its tip meets the trigger's edge.
301301+ background-color: inherit keeps it in step with a bubbleClass that repaints the bubble. */
302302+ .tooltip-bubble[data-arrow]::after {
303303+ content: "";
304304+ position: absolute;
305305+ background-color: inherit;
306306+ }
307307+308308+ .tooltip-bubble[data-arrow][data-resolved-side="top"]::after {
309309+ top: 100%;
310310+ left: var(--tooltip-arrow);
311311+ width: 0.5rem;
312312+ height: 0.25rem;
313313+ translate: -50% 0;
314314+ clip-path: polygon(50% 100%, 0 0, 100% 0);
315315+ }
316316+317317+ .tooltip-bubble[data-arrow][data-resolved-side="bottom"]::after {
318318+ bottom: 100%;
319319+ left: var(--tooltip-arrow);
320320+ width: 0.5rem;
321321+ height: 0.25rem;
322322+ translate: -50% 0;
323323+ clip-path: polygon(50% 0, 0 100%, 100% 100%);
324324+ }
325325+326326+ .tooltip-bubble[data-arrow][data-resolved-side="left"]::after {
327327+ left: 100%;
328328+ top: var(--tooltip-arrow);
329329+ width: 0.25rem;
330330+ height: 0.5rem;
331331+ translate: 0 -50%;
332332+ clip-path: polygon(100% 50%, 0 0, 0 100%);
333333+ }
334334+335335+ .tooltip-bubble[data-arrow][data-resolved-side="right"]::after {
336336+ right: 100%;
337337+ top: var(--tooltip-arrow);
338338+ width: 0.25rem;
339339+ height: 0.5rem;
340340+ translate: 0 -50%;
341341+ clip-path: polygon(0 50%, 100% 0, 100% 100%);
342342+ }
343343+344344+ .tooltip-bubble {
345345+ opacity: 0;
346346+ transition:
347347+ opacity 100ms ease-out,
348348+ overlay 100ms allow-discrete,
349349+ display 100ms allow-discrete;
350350+ }
351351+352352+ .tooltip-bubble:popover-open {
353353+ opacity: 1;
354354+ }
355355+356356+ @starting-style {
357357+ .tooltip-bubble:popover-open {
358358+ opacity: 0;
359359+ }
360360+ }
361361+362362+ @media (prefers-reduced-motion: reduce) {
363363+ .tooltip-bubble {
364364+ transition: none;
365365+ }
366366+ }
367367+</style>