Theme
On this page (11)
Component Skill — components/tooltip/component-skill.md

Native basis

Popover API (popover="hint") for hover/focus hint popups with CSS anchor positioning for placement.

Web Platform APIs

popover (hint)CSS Anchor Positioningposition-areaposition-try-fallbacks@starting-style

Classes

.tooltip

Data attributes

• data-tooltip-trigger - on trigger element, value is the tooltip ID

• data-side - on tooltip: top | bottom | left | right

• data-align - on tooltip: start | center | end

• data-arrow - on a child <div> inside tooltip for connecting caret

• data-delay - on trigger element, open delay in ms (default: 700)

• data-close-delay - on trigger element, close delay in ms (default: 0)

Wiring conventions

• data-tooltip-trigger on any element - opens the component

§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:

StateTypeValuesDefaultDescription
visiblebooleantrue, falsefalseHint 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 opens
const CLOSE_DELAY_DEFAULT = 0;  // ms before tooltip closes
const GROUP_TIMEOUT = 400;      // ms after last tooltip hides before delay resets
let groupOpen = false;       // true while any tooltip is visible
let groupTimer = null;       // timeout to reset groupOpen
function 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