← All UI specs

Component contract

tooltip

WAI-ARIA Tooltip pattern (scratch build) — wraps the consumer's first Element child as the trigger, opens on hover (with openDelay) and focus (immediate), dismisses on Escape while keeping the trigger focused, and closes on blur or genuine mouseleave. shadow:false so aria-describedby on the trigger can reference the tooltip's id.

Version
v1.0.0

Props

NameTypeValuesDefault
labelstring
openDelaynumber250
closeDelaynumber100
placementenumtop, bottombottom

Slot

Name
default
Purpose
the trigger — the first Element child inside ds-tooltip is treated as the trigger and MUST be focusable (native button/link/form control, or an element with tabindex=0). The tooltip is rendered as a sibling <span role="tooltip"> at the end of the host's children.

Tokens

spacing
xs, sm
radius
sm
font
family, size-md

State machine

Initial state: closed

closed

MOUSE_ENTER
→ opening · start_open_timer(openDelay)
FOCUS_IN
→ open · show_immediately

opening

TIMER_ELAPSED
→ open · show
MOUSE_LEAVE
→ closed · cancel_open_timer

open

MOUSE_LEAVE
→ closing · start_close_timer(closeDelay)
FOCUS_OUT
→ closed · hide_immediately
ESCAPE
→ closed · hide_immediately, keep_focus_on_trigger

closing

TIMER_ELAPSED
→ closed · hide
MOUSE_ENTER
→ open · cancel_close_timer

Accessibility

native_element
span[role=tooltip] alongside the consumer trigger (scratch-built)
apg_pattern
https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/
keyboard
["Focus (Tab into trigger) → tooltip opens immediately (no openDelay for keyboard)","Escape (while open) → dismisses without moving focus; trigger remains focused (WCAG 1.4.13 dismissable)","Blur (Tab out of trigger) → tooltip closes immediately","Enter / Space / arrow / printable → forwarded to trigger untouched"]
pointer
["mouseenter host → open after openDelay (ergonomic against twitchy tooltips)","mouseleave host → close after closeDelay","re-entering during closeDelay cancels the hide (hoverable — WCAG 1.4.13)"]
aria
{"tooltip_element":"role=tooltip, unique id per instance (auto-generated to avoid collisions on pages with multiple tooltips)","trigger":"aria-describedby=<tooltipId> set imperatively on the first Element child in componentDidLoad"}
wcag_1_4_13_content_on_hover_or_focus
{"dismissable":"Escape hides the tooltip without moving focus","hoverable":"closeDelay + cancel-on-re-enter allow the pointer to move onto the tooltip surface without dismissing it","persistent":"tooltip stays visible while trigger is focused or hovered and while the pointer is over the tooltip surface"}
consumer_contract
["The first Element child inside <ds-tooltip> MUST be focusable — otherwise keyboard users cannot summon the tooltip and the pattern fails WCAG 1.4.13. The component does not force focusability onto arbitrary children.","`label` is REQUIRED — a tooltip with no accessible text is inert.","The tooltip is describedby, NOT labelledby — it supplements the trigger's own accessible name (which comes from the trigger's text or aria-label)."]
notes
Scratch-built per the a11y spec §D ceremony because there is no native tooltip element. shadow:false is intentional: aria-describedby needs to reference the tooltip's id from the light-DOM trigger, and shadow-scoped ids are unreachable.
pending_manual_verification
["VoiceOver on macOS: confirm the tooltip text is announced as a description of the trigger when the trigger gains focus","VoiceOver: confirm Escape dismisses the tooltip and leaves the trigger focused; re-focusing brings it back","Chromium reduced-motion: confirm no residual animation is added by the component"]
Raw validated JSON
{
  "name": "tooltip",
  "version": "1.0.0",
  "props": {
    "label": {
      "type": "string"
    },
    "openDelay": {
      "type": "number",
      "default": 250
    },
    "closeDelay": {
      "type": "number",
      "default": 100
    },
    "placement": {
      "type": "enum",
      "values": [
        "top",
        "bottom"
      ],
      "default": "bottom"
    }
  },
  "slot": {
    "name": "default",
    "purpose": "the trigger — the first Element child inside ds-tooltip is treated as the trigger and MUST be focusable (native button/link/form control, or an element with tabindex=0). The tooltip is rendered as a sibling <span role=\"tooltip\"> at the end of the host's children."
  },
  "state_machine": {
    "initial": "closed",
    "states": {
      "closed": {
        "on": {
          "MOUSE_ENTER": {
            "target": "opening",
            "actions": [
              "start_open_timer(openDelay)"
            ]
          },
          "FOCUS_IN": {
            "target": "open",
            "actions": [
              "show_immediately"
            ]
          }
        }
      },
      "opening": {
        "on": {
          "TIMER_ELAPSED": {
            "target": "open",
            "actions": [
              "show"
            ]
          },
          "MOUSE_LEAVE": {
            "target": "closed",
            "actions": [
              "cancel_open_timer"
            ]
          }
        }
      },
      "open": {
        "on": {
          "MOUSE_LEAVE": {
            "target": "closing",
            "actions": [
              "start_close_timer(closeDelay)"
            ]
          },
          "FOCUS_OUT": {
            "target": "closed",
            "actions": [
              "hide_immediately"
            ]
          },
          "ESCAPE": {
            "target": "closed",
            "actions": [
              "hide_immediately",
              "keep_focus_on_trigger"
            ]
          }
        }
      },
      "closing": {
        "on": {
          "TIMER_ELAPSED": {
            "target": "closed",
            "actions": [
              "hide"
            ]
          },
          "MOUSE_ENTER": {
            "target": "open",
            "actions": [
              "cancel_close_timer"
            ]
          }
        }
      }
    }
  },
  "tokens_used": {
    "spacing": [
      "xs",
      "sm"
    ],
    "radius": [
      "sm"
    ],
    "font": [
      "family",
      "size-md"
    ]
  },
  "a11y": {
    "native_element": "span[role=tooltip] alongside the consumer trigger (scratch-built)",
    "apg_pattern": "https://www.w3.org/WAI/ARIA/apg/patterns/tooltip/",
    "keyboard": [
      "Focus (Tab into trigger) → tooltip opens immediately (no openDelay for keyboard)",
      "Escape (while open) → dismisses without moving focus; trigger remains focused (WCAG 1.4.13 dismissable)",
      "Blur (Tab out of trigger) → tooltip closes immediately",
      "Enter / Space / arrow / printable → forwarded to trigger untouched"
    ],
    "pointer": [
      "mouseenter host → open after openDelay (ergonomic against twitchy tooltips)",
      "mouseleave host → close after closeDelay",
      "re-entering during closeDelay cancels the hide (hoverable — WCAG 1.4.13)"
    ],
    "aria": {
      "tooltip_element": "role=tooltip, unique id per instance (auto-generated to avoid collisions on pages with multiple tooltips)",
      "trigger": "aria-describedby=<tooltipId> set imperatively on the first Element child in componentDidLoad"
    },
    "wcag_1_4_13_content_on_hover_or_focus": {
      "dismissable": "Escape hides the tooltip without moving focus",
      "hoverable": "closeDelay + cancel-on-re-enter allow the pointer to move onto the tooltip surface without dismissing it",
      "persistent": "tooltip stays visible while trigger is focused or hovered and while the pointer is over the tooltip surface"
    },
    "consumer_contract": [
      "The first Element child inside <ds-tooltip> MUST be focusable — otherwise keyboard users cannot summon the tooltip and the pattern fails WCAG 1.4.13. The component does not force focusability onto arbitrary children.",
      "`label` is REQUIRED — a tooltip with no accessible text is inert.",
      "The tooltip is describedby, NOT labelledby — it supplements the trigger's own accessible name (which comes from the trigger's text or aria-label)."
    ],
    "notes": "Scratch-built per the a11y spec §D ceremony because there is no native tooltip element. shadow:false is intentional: aria-describedby needs to reference the tooltip's id from the light-DOM trigger, and shadow-scoped ids are unreachable.",
    "pending_manual_verification": [
      "VoiceOver on macOS: confirm the tooltip text is announced as a description of the trigger when the trigger gains focus",
      "VoiceOver: confirm Escape dismisses the tooltip and leaves the trigger focused; re-focusing brings it back",
      "Chromium reduced-motion: confirm no residual animation is added by the component"
    ]
  }
}