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 (11)
§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). |
§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 } from '../../shared/state-api.js';const df$ = defussGlobals();const tooltipStates = ['default', 'visible'];/** * 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 = { setState(tip, stateName, config = {}) { if (!tooltipStates.includes(stateName)) { throw new Error(`tooltip: unknown state "${stateName}" (supported: ${tooltipStates.join(', ')})`); } triggerStateChange(tip, stateName, config); // state lives on the ELEMENT, not the module (many tooltips per page) tip.dataset.stateName = stateName; tip._stateConfig = config; }, getState(tip) { return { name: tip.dataset.stateName || 'default', config: tip._stateConfig ?? {} }; },};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() {document.querySelectorAll('[data-tooltip-trigger]:not([data-init])').forEach((trigger) => { trigger.dataset.init = ''; const tip = document.getElementById(trigger.dataset.tooltipTrigger); 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')` document.querySelectorAll('.tooltip[popover]:not([data-init])').forEach((tip) => { tip.dataset.init = ''; tip.api = { setState: (stateName, config) => tooltipApi.setState(tip, stateName, config), getState: () => tooltipApi.getState(tip), }; });}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', () => { document.querySelectorAll('.tooltip:popover-open').forEach((tip) => { try { tip.hidePopover(); } catch {} }); }, { passive: true, capture: true });}Comments, ideas or improvements? Edit this page's source on GitHub