TooltipATM
A popup that displays information related to an element when the element receives keyboard focus or the mouse hovers over it. Uses the Popover API with CSS anchor positioning for placement.
On this page (12)
§Default
§Side
Use data-side on the tooltip to control placement.
§With Arrow
Add <div data-arrow></div> inside the tooltip for a connecting caret.
§With Keyboard Shortcut
§Alignment
Use data-align to control tooltip alignment relative to the trigger.
§Disabled Button
Wrap a disabled button in a <span> with the trigger attribute, since disabled elements don't fire mouse events.
§Custom Delay
Use data-delay on the trigger to override the default 700 ms open delay.
§Group Behavior
Once any tooltip opens, subsequent tooltips in the page skip the delay and appear instantly. After 400 ms with no tooltip visible, the delay resets. Hover across the buttons below to see the effect.
§States
Named states via the shared State API, driven per tooltip through the bound api:
default- hidden (the authored state; hover/focus reveal with delay)visible- shown immediately, bypassing the hover delay
The first demo carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/tooltip-{state}.png.
Machine contract - verified against tooltip.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
visible | boolean | true, false | false | Hint shown (popover="hint" - opening a hint does not dismiss other popovers). |
§API
Generated from tooltip.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type TooltipState = 'default' | 'visible' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | Hidden. No config. |
visible | Shown (popover="hint"), anchored to its trigger. No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends TooltipState>(name: S, config?: TooltipStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: TooltipState; config: TooltipStateConfigs[TooltipState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.tooltipApi.setState<S extends TooltipState>(el: HTMLElement, name: S, config?: TooltipStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.tooltipApi.getState(el: HTMLElement): { name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.tooltipApi.render(state: { name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.tooltipApi.store(el: HTMLElement): Store<{ name: TooltipState; config: TooltipStateConfigs[TooltipState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.tooltipApi.commit<S extends TooltipState>(el: HTMLElement, name: S, config?: TooltipStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.tooltipStates: TooltipState[] | The declared states, 'default' first: default, visible. |
§CSS view file
Uses position-area for placement, position-try-fallbacks for collision avoidance, and @starting-style for direction-aware animations.
@layer components { .tooltip { position: fixed; inset: auto; margin: 0; /* The UA popover stylesheet sets overflow:auto - the [data-arrow] caret sits OUTSIDE the border box (-4px offsets), so it got clipped to a sliver and overflow:auto rendered a scrollbar around the text. */ overflow: visible; border: none; border-radius: var(--radius-md); background-color: var(--primary); color: var(--primary-foreground); padding: 0.375rem 0.75rem; font-size: 0.75rem; line-height: 1.4; box-shadow: var(--shadow-sm); pointer-events: none; max-width: 16rem; width: max-content; opacity: 0; transition: opacity 150ms ease, translate 150ms ease, display 150ms allow-discrete; /* -- Default: top center -- */ position-area: top; margin-bottom: 6px; translate: 0 2px; position-try-fallbacks: flip-block, flip-inline; &:popover-open { opacity: 1; translate: 0 0; } /* ----- Side variants ----- */ &[data-side="bottom"] { position-area: bottom; margin-bottom: 0; margin-top: 6px; translate: 0 -2px; &:popover-open { translate: 0 0; } } &[data-side="left"] { position-area: left; margin-bottom: 0; margin-right: 6px; translate: 2px 0; &:popover-open { translate: 0 0; } } &[data-side="right"] { position-area: right; margin-bottom: 0; margin-left: 6px; translate: -2px 0; &:popover-open { translate: 0 0; } } /* ----- Align variants (combined with side) ----- span-*: the tooltip starts AT the trigger's edge and extends past it. (The corner areas - "top left" - put it entirely beside the trigger, pointing at nothing.) start = shares the trigger's start edge. */ &[data-align="start"] { &:not([data-side="left"]):not([data-side="right"]) { position-area: top span-right; } &[data-side="bottom"] { position-area: bottom span-right; } &[data-side="left"] { position-area: left span-bottom; } &[data-side="right"] { position-area: right span-bottom; } } &[data-align="end"] { &:not([data-side="left"]):not([data-side="right"]) { position-area: top span-left; } &[data-side="bottom"] { position-area: bottom span-left; } &[data-side="left"] { position-area: left span-top; } &[data-side="right"] { position-area: right span-top; } } /* ----- Arrow ----- */ & [data-arrow] { position: absolute; width: 8px; height: 8px; background: inherit; rotate: 45deg; border: none; } /* Arrow placement - default (top side): arrow at bottom center */ &:not([data-side]), &[data-side="top"] { & [data-arrow] { bottom: -4px; left: 50%; margin-left: -4px; } } &[data-side="bottom"] { & [data-arrow] { top: -4px; left: 50%; margin-left: -4px; } } &[data-side="left"] { & [data-arrow] { right: -4px; top: 50%; margin-top: -4px; } } &[data-side="right"] { & [data-arrow] { left: -4px; top: 50%; margin-top: -4px; } } /* Aligned tooltips: the arrow sits near the shared edge, so it points into the trigger (a centred arrow would point past a narrow trigger) */ &[data-align="start"]:not([data-side="left"]):not([data-side="right"]) [data-arrow] { left: 1rem; margin-left: -4px; } &[data-align="end"]:not([data-side="left"]):not([data-side="right"]) [data-arrow] { left: auto; right: 1rem; margin-left: 0; margin-right: -4px; } &[data-align="start"]:is([data-side="left"], [data-side="right"]) [data-arrow] { top: 0.75rem; margin-top: -4px; } &[data-align="end"]:is([data-side="left"], [data-side="right"]) [data-arrow] { top: auto; bottom: 0.75rem; margin-top: 0; margin-bottom: -4px; } } @starting-style { .tooltip:popover-open { opacity: 0; translate: 0 2px; } .tooltip[data-side="bottom"]:popover-open { opacity: 0; translate: 0 -2px; } .tooltip[data-side="left"]:popover-open { opacity: 0; translate: 2px 0; } .tooltip[data-side="right"]:popover-open { opacity: 0; translate: -2px 0; } }}/* Accessibility: suppress motion for users who request it (REQUIRED for all components - AGENTS.md "Accessibility CSS"). Near-zero duration instead of `none` keeps transitionend/animationend (and discrete display flips) firing so JS state machines that await them keep working. */@media (prefers-reduced-motion: reduce) { @layer components { .tooltip, .tooltip *, .tooltip::before, .tooltip::after, .tooltip *::before, .tooltip *::after, .tooltip::backdrop { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; } }}§JavaScript view file
Delay, group behavior, ARIA wiring, and scroll dismiss.
// -- Tooltip --------------------------------------------------// Popover API tooltips with delay, group behavior, ARIA wiring,// CSS anchor positioning, and scroll dismiss, plus the named-state API// so agents/tests can drive visibility by name (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, safeShowPopover, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const tooltipStates = ['default', 'visible'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state - the tooltip's states take none. */export interface TooltipStateConfigs { /** Hidden. */ default: {}; /** Shown (popover="hint"), anchored to its trigger. */ visible: {};}/** * The markup of a state: none - 'visible' lives in the top layer * (:popover-open), not in an attribute, so every state renders the authored * markup. render() stays the State API's markup function all the same. */function applyMarkup(_el, _stateName) {}/** * UI side of setState: 'default' hides, 'visible' shows immediately * (bypasses the hover delay - a declared state is imperative, not hover-sim). */function triggerStateChange(tip, stateName, _config) { switch (stateName) { case 'default': try { tip.hidePopover(); } catch { /* already closed */ } break; case 'visible': // deferred show (safeShowPopover): showPopover() mid-exit (after // scroll-hiding) crashes the headless renderer; hints stay hints. safeShowPopover(tip); markGroupOpen(); break; }}/** Registry-level API; pass the tooltip element explicitly. Unknown names throw. */export const tooltipApi = componentState({ component: 'tooltip', states: tooltipStates, apply: (tip, state) => triggerStateChange(tip, state.name, state.config), markup: (el, state) => applyMarkup(el, state.name),});df$.tooltipApi = tooltipApi;df$.tooltipStates = tooltipStates;const DELAY_DEFAULT = 700; // ms before first tooltip opensconst CLOSE_DELAY_DEFAULT = 0; // ms before tooltip closesconst GROUP_TIMEOUT = 400; // ms after last tooltip hides before delay resetslet groupOpen = false; // true while any tooltip is visiblelet groupTimer = null; // timeout to reset groupOpenfunction markGroupOpen() { groupOpen = true; clearTimeout(groupTimer);}function scheduleGroupReset() { clearTimeout(groupTimer); groupTimer = setTimeout(() => { groupOpen = false; }, GROUP_TIMEOUT);}function init() {dfDollar('[data-tooltip-trigger]:not([data-init])').toArray().forEach((trigger) => { trigger.dataset.init = ''; const tip = dfDollar('#' + CSS.escape(trigger.dataset.tooltipTrigger)).get(0); if (!tip) return; // CSS anchor positioning - unique name per trigger-tooltip pair const anchorId = `--tooltip-${tip.id}`; trigger.style.anchorName = anchorId; tip.style.positionAnchor = anchorId; // ARIA - link trigger to tooltip trigger.setAttribute('aria-describedby', tip.id); const delay = Number(trigger.dataset.delay ?? DELAY_DEFAULT); const closeDelay = Number(trigger.dataset.closeDelay ?? CLOSE_DELAY_DEFAULT); let openTimer = null; let closeTimer = null; function show() { clearTimeout(closeTimer); clearTimeout(openTimer); const wait = groupOpen ? 0 : delay; openTimer = setTimeout(() => { try { tip.showPopover(); } catch { /* already open */ } markGroupOpen(); }, wait); } function hide() { clearTimeout(openTimer); clearTimeout(closeTimer); closeTimer = setTimeout(() => { try { tip.hidePopover(); } catch { /* already closed */ } scheduleGroupReset(); }, closeDelay); } trigger.addEventListener('mouseenter', show); trigger.addEventListener('mouseleave', hide); trigger.addEventListener('focus', show); trigger.addEventListener('blur', hide);}); // bind-scope the api per tooltip instance: `$('#tip').api.setState('visible')` dfDollar('.tooltip[popover]:not([data-init])').toArray().forEach((tip) => { tip.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores") bindComponent(tip, tooltipApi); });}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });// -- Scroll dismiss -------------------------------------------// Hide any open tooltip when the page scrolls.if (!document.__tooltipScrollInit) { document.__tooltipScrollInit = true; document.addEventListener('scroll', () => { dfDollar('.tooltip:popover-open').toArray().forEach((tip) => { try { tip.hidePopover(); } catch {} }); }, { passive: true, capture: true });}Comments, ideas or improvements? Edit this page's source on GitHub