Theme
Design your own
On this page (12)
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).

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

StateDescription
default
Hidden.

No config.

visible
Shown (popover="hint"), anchored to its trigger.

No config.

Every element

MemberDescription
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.
ArgumentTypeDescription
nameSa declared state (an unknown name throws)
config?TooltipStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (or await settled())

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 { name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state?{ name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel }a state as getState() returns it (default: the current one)

Returns string - the element's outer HTML in that state

el.api.settled(): Promise<void>
Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting).

Returns Promise<void> - resolves when nothing is pending

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

MemberDescription
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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSa declared state (an unknown name throws)
config?TooltipStateConfigs[S]that state's config (merged into the stored one when the component merges)

Returns unknown - what the state's DOM work returned - a Promise for an async state (await it, or el.api.settled())

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.
ArgumentTypeDescription
elHTMLElementthe component's element

Returns { name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel } - the state's name, its config and the authored markup model render() starts from

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.
ArgumentTypeDescription
state{ name: TooltipState; config: TooltipStateConfigs[TooltipState]; model?: ElementModel }a state as getState() returns it (with its model)

Returns string - the element's outer HTML in that state

df$.shadcn.tooltipApi.store(el: HTMLElement): Store<{ name: TooltipState; config: TooltipStateConfigs[TooltipState] }>
The element's store (bindComponent made it).
ArgumentTypeDescription
elHTMLElementthe component's element

Returns Store<{ name: TooltipState; config: TooltipStateConfigs[TooltipState] }> - a defuss-store store of { name, config } - subscribe to follow every change, set it to drive the component

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.
ArgumentTypeDescription
elHTMLElementthe component's element
nameSthe state it is in
config?TooltipStateConfigs[S]its config
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 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() {
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