Skip to content

Tooltip

A lightweight floating label that appears on hover, focus, or click. Automatically flips placement when near viewport edges and dismisses on Escape.

Features

  • 📍 4 Placements: top (default), bottom, left, right — with viewport-aware auto-flip
  • 3 Trigger Modes: hover, focus, click — comma-separated for combinations
  • ⏱️ Show Delay: configurable delay before appearing
  • 🎨 2 Variants: dark (default), light
  • 📏 3 Sizes: sm, md, lg
  • Accessible: role="tooltip", aria-describedby wiring, keyboard Escape dismiss
  • 🔧 Powered by floatit: uses @vielzeug/floatit for viewport-aware auto-positioning (flip, shift, autoUpdate)

Source Code

View Source Code
ts
import type { Placement } from '@vielzeug/floatit';

import { computed, createId, defineComponent, html, onMount, onSlotChange, signal, watch } from '@vielzeug/craftit';
import { autoUpdate, computePosition, flip, offset, shift } from '@vielzeug/floatit';

import type { ComponentSize } from '../../types';

import { forcedColorsMixin } from '../../styles';

type TooltipPlacement = 'top' | 'bottom' | 'left' | 'right';
type TooltipTrigger = 'hover' | 'focus' | 'click';

const TOOLTIP_OFFSET = 8; // gap from trigger to tooltip edge
const LEFT_GAP_COMPENSATION = 4; // left placement looks visually tighter in practice

import styles from './tooltip.css?inline';

/** Tooltip component properties */
export type BitTooltipProps = {
  /** Hide delay in ms (useful to keep tooltip open when moving focus between trigger and tooltip) */
  'close-delay'?: number;
  /** Tooltip text content */
  content?: string;
  /** Show delay in ms */
  delay?: number;
  /** Disable the tooltip */
  disabled?: boolean;
  /** Controlled open state. When provided, the tooltip acts as a controlled component and ignores trigger events for open/close. */
  open?: boolean;
  /** Preferred placement relative to trigger */
  placement?: TooltipPlacement;
  /** Tooltip size */
  size?: ComponentSize;
  /** Which trigger(s) show/hide the tooltip — comma-separated if multiple, e.g. "hover,focus" */
  trigger?: string;
  /** Visual variant: 'dark' (default) or 'light' */
  variant?: 'dark' | 'light';
};

/**
 * A lightweight tooltip shown on hover/focus/click relative to the slotted trigger.
 *
 * @element bit-tooltip
 *
 * @attr {string} content - Tooltip text content
 * @attr {string} placement - 'top' | 'bottom' | 'left' | 'right' (default: 'top')
 * @attr {string} trigger - 'hover' | 'focus' | 'click' or comma-separated combination
 * @attr {number} delay - Show delay in milliseconds (default: 0)
 * @attr {string} size - Size: 'sm' | 'md' | 'lg'
 * @attr {string} variant - 'dark' (default) | 'light'
 * @attr {boolean} disabled - Disable the tooltip
 *
 * @slot - Trigger element that the tooltip is anchored to
 * @slot content - Complex tooltip content (overrides the `content` attribute)
 *
 * @cssprop --tooltip-max-width - Max width of the tooltip bubble
 *
 * @example
 * ```html
 * <bit-tooltip content="Copy to clipboard">
 *   <button>Copy</button>
 * </bit-tooltip>
 *
 * <bit-tooltip placement="right" trigger="focus,hover" content="Required field">
 *   <input type="text" />
 * </bit-tooltip>
 * ```
 */
export const TOOLTIP_TAG = defineComponent<BitTooltipProps>({
  props: {
    'close-delay': { default: 0 },
    content: { default: '' },
    delay: { default: 0 },
    disabled: { default: false },
    open: { default: undefined },
    placement: { default: 'top' },
    size: { default: undefined },
    trigger: { default: 'hover,focus' },
    variant: { default: undefined },
  },
  setup({ host, props }) {
    const visible = signal(false);
    const activePlacement = signal<TooltipPlacement>('top');
    let autoUpdateCleanup: (() => void) | null = null;
    let showTimer: ReturnType<typeof setTimeout> | null = null;
    let hideTimer: ReturnType<typeof setTimeout> | null = null;
    let tooltipEl: HTMLElement | null = null;
    const tooltipId = createId('tooltip');
    const triggers = computed<TooltipTrigger[]>(() =>
      String(props.trigger.value)
        .split(',')
        .map((t: string) => t.trim() as TooltipTrigger)
        .filter(Boolean),
    );

    function getTriggerEl(): Element | null {
      // First slotted element is the trigger
      const slot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');
      const assigned = slot?.assignedElements({ flatten: true });

      return assigned?.[0] ?? null;
    }
    function updatePosition() {
      if (!tooltipEl) return;

      const triggerEl = getTriggerEl();

      if (!triggerEl) return;

      computePosition(triggerEl, tooltipEl, {
        middleware: [offset(TOOLTIP_OFFSET), flip(), shift({ padding: 6 })],
        placement: props.placement.value as Placement,
      }).then(({ placement, x, y }) => {
        if (!tooltipEl) return;

        const side = placement.split('-')[0] as TooltipPlacement;
        const adjustedX = side === 'left' ? x - LEFT_GAP_COMPENSATION : x;

        tooltipEl.style.left = `${adjustedX}px`;
        tooltipEl.style.top = `${y}px`;

        activePlacement.value = side;
      });
    }
    function show() {
      if (props.open.value !== undefined) return; // controlled mode

      const hasSlottedContent = () => {
        const contentSlot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot[name="content"]');

        return (contentSlot?.assignedNodes({ flatten: true }).length ?? 0) > 0;
      };

      if (props.disabled.value || (!props.content.value && !hasSlottedContent())) return;

      if (hideTimer) {
        clearTimeout(hideTimer);
        hideTimer = null;
      }

      if (showTimer) clearTimeout(showTimer);

      showTimer = setTimeout(
        () => {
          visible.value = true;

          if (tooltipEl && !tooltipEl.matches(':popover-open')) {
            tooltipEl.showPopover();
          }

          // Start autoUpdate: repositions on scroll, resize, and reference size change
          const triggerEl = getTriggerEl();

          if (triggerEl && tooltipEl) {
            autoUpdateCleanup?.();
            autoUpdateCleanup = autoUpdate(triggerEl, tooltipEl, updatePosition);
          } else {
            requestAnimationFrame(() => updatePosition());
          }
        },
        Number(props.delay.value) || 0,
      );
    }
    function hide() {
      if (props.open.value !== undefined) return; // controlled mode

      if (showTimer) {
        clearTimeout(showTimer);
        showTimer = null;
      }

      const closeDelay = Number(props['close-delay'].value) || 0;

      if (closeDelay > 0) {
        if (hideTimer) clearTimeout(hideTimer);

        hideTimer = setTimeout(() => {
          hideTimer = null;
          _doHide();
        }, closeDelay);
      } else {
        _doHide();
      }
    }
    function _doHide() {
      autoUpdateCleanup?.();
      autoUpdateCleanup = null;
      visible.value = false;

      if (tooltipEl?.matches(':popover-open')) {
        tooltipEl.hidePopover();
      }
    }
    function toggleClick() {
      if (visible.value) hide();
      else show();
    }
    onMount(() => {
      const slot = host.shadowRoot?.querySelector<HTMLSlotElement>('slot:not([name])');

      if (!slot) return;

      const bindTriggerEvents = () => {
        unbindTriggerEvents(); // clean up previous bindings

        const triggerEl = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;

        if (!triggerEl) return;

        triggerEl.setAttribute('aria-describedby', tooltipId);

        const t = triggers.value;

        if (t.includes('hover')) {
          triggerEl.addEventListener('pointerenter', show);
          triggerEl.addEventListener('pointerleave', hide);
        }

        if (t.includes('focus')) {
          triggerEl.addEventListener('focusin', show);
          triggerEl.addEventListener('focusout', hide);
        }

        if (t.includes('click')) {
          triggerEl.addEventListener('click', toggleClick);
        }

        // Keyboard escape to dismiss
        document.addEventListener('keydown', handleKeydown);
      };
      const unbindTriggerEvents = () => {
        const triggerEl = slot.assignedElements({ flatten: true })[0] as HTMLElement | undefined;

        if (!triggerEl) return;

        triggerEl.removeAttribute('aria-describedby');
        triggerEl.removeEventListener('pointerenter', show);
        triggerEl.removeEventListener('pointerleave', hide);
        triggerEl.removeEventListener('focusin', show);
        triggerEl.removeEventListener('focusout', hide);
        triggerEl.removeEventListener('click', toggleClick);
        document.removeEventListener('keydown', handleKeydown);
      };

      onSlotChange('default', bindTriggerEvents);
      // Controlled mode: watch the `open` prop and show/hide accordingly
      watch(props.open, (openVal) => {
        if (openVal === undefined || openVal === null) return;

        if (openVal) {
          visible.value = true;

          if (tooltipEl && !tooltipEl.matches(':popover-open')) tooltipEl.showPopover();

          const triggerEl = getTriggerEl();

          if (triggerEl && tooltipEl) {
            autoUpdateCleanup?.();
            autoUpdateCleanup = autoUpdate(triggerEl, tooltipEl, updatePosition);
          } else {
            requestAnimationFrame(() => updatePosition());
          }
        } else {
          _doHide();
        }
      });

      return () => {
        unbindTriggerEvents();

        if (showTimer) clearTimeout(showTimer);

        if (hideTimer) clearTimeout(hideTimer);

        autoUpdateCleanup?.();
        autoUpdateCleanup = null;

        if (tooltipEl?.matches(':popover-open')) {
          tooltipEl.hidePopover();
        }
      };
    });
    function handleKeydown(e: KeyboardEvent) {
      if (e.key === 'Escape') hide();
    }

    return html`
      <slot></slot>
      <div
        class="tooltip"
        part="tooltip"
        id="${tooltipId}"
        role="tooltip"
        popover="manual"
        ref=${(el: HTMLElement) => {
          tooltipEl = el;
        }}
        :data-placement="${activePlacement}"
        :aria-hidden="${() => String(!visible.value)}">
        <slot name="content">${() => props.content.value}</slot>
      </div>
    `;
  },
  styles: [forcedColorsMixin, styles],
  tag: 'bit-tooltip',
});

Basic Usage

Wrap any element with bit-tooltip and set the content attribute.

html
<bit-tooltip content="Copy to clipboard">
  <button>Copy</button>
</bit-tooltip>

<script type="module">
  import '@vielzeug/buildit/tooltip';
</script>

Placement

PreviewCode
RTL

Trigger Modes

PreviewCode
RTL

Variants

PreviewCode
RTL

Sizes

PreviewCode
RTL

Show Delay

Use delay (milliseconds) to add a pause before the tooltip shows — useful for dense UIs.

PreviewCode
RTL

Rich Content via Slot

For complex tooltip content, use the content named slot.

PreviewCode
RTL

Disabled Tooltips

Set disabled to suppress the tooltip entirely.

html
<bit-tooltip content="This won't show" disabled>
  <bit-button>No tooltip</bit-button>
</bit-tooltip>

API Reference

Attributes

AttributeTypeDefaultDescription
contentstring''Tooltip text
placement'top' | 'bottom' | 'left' | 'right''top'Preferred placement (auto-flips near viewport edges)
triggerstring'hover,focus'Trigger mode(s), comma-separated
delaynumber0Show delay in milliseconds
close-delaynumber0Hide delay in milliseconds — useful to keep the tooltip open when moving between trigger and bubble
openbooleanControlled open state; when set, trigger events are ignored
variant'dark' | 'light'Visual style (dark appearance is the unset default)
size'sm' | 'md' | 'lg'Tooltip bubble size (medium appearance is the unset default)
disabledbooleanfalseDisable the tooltip entirely

Slots

SlotDescription
(default)The trigger element the tooltip is anchored to
contentRich tooltip content (overrides the content attribute)

CSS Custom Properties

PropertyDescriptionDefault
--tooltip-max-widthMax width of the bubble18rem

Accessibility

The tooltip component follows WAI-ARIA best practices.

bit-tooltip

Keyboard Navigation

  • Pressing Escape while a tooltip is visible dismisses it.

Screen Readers

  • The tooltip bubble has role="tooltip".
  • The trigger element is augmented with aria-describedby pointing to the tooltip — this happens automatically when using the focus trigger.

TIP

When trigger includes focus, the tooltip is automatically wired as a programmatic description for the focused element, which benefits screen reader users.

Best Practices

Do:

  • Keep tooltip text short — one sentence or a keyboard shortcut label.
  • Use trigger="focus" (or "hover,focus") for form field hints so keyboard-only users see them.
  • Use delay (e.g. 400600ms) in action-dense toolbars to avoid visual noise on quick cursor sweeps.
  • Prefer variant="light" on dark backgrounds.

Don't:

  • Use tooltips to hold essential information — if the user must see it to act, put it in helper text or an alert instead.
  • Add interactive elements (buttons, links) inside the tooltip bubble; tooltips are not focusable.