// pattern

Accessible Tooltip Pattern

A tooltip that displays supplementary text on hover and focus using role="tooltip" and aria-describedby. Includes a toggletip variant for dynamic content with aria-live. Dismissable with Escape per WCAG 1.4.13.

aria-required intermediate wcag-2.2

// 01 · live demo

Live Demo

Hover or focus each trigger to see the tooltip. Press Escape to dismiss.

Settings
Icon button
WCAG Web Content Accessibility Guidelines
Abbreviation
Toggletip

Inaccessible version (title attribute):

title attribute only

This div uses only the title attribute. It cannot be focused with a keyboard, the tooltip cannot be dismissed, it cannot be styled, and screen readers may ignore it entirely.

// 02 · the code

The Code

HTML
<!-- Tooltip on an icon button -->
<div class="tooltip-wrap">
  <button type="button"
          aria-label="Settings"
          aria-describedby="tip-settings">
    <!-- icon SVG -->
  </button>
  <span role="tooltip" id="tip-settings">
    Settings
  </span>
</div>

<!-- Tooltip on an abbreviation -->
<div class="tooltip-wrap">
  <span tabindex="0"
        aria-describedby="tip-wcag">
    WCAG
  </span>
  <span role="tooltip" id="tip-wcag">
    Web Content Accessibility Guidelines
  </span>
</div>

<!-- Toggletip: button + live region -->
<div class="toggletip-wrap">
  <button type="button"
          aria-label="More info about ARIA roles">
    ?
  </button>
  <div role="status" hidden>
    <p>ARIA roles tell assistive technology
       what an element does.</p>
  </div>
</div>
CSS
.tooltip-wrap {
  position: relative;
  display: inline-block;
}

/* Tooltip bubble */
.tooltip-wrap [role="tooltip"] {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  padding: 0.25rem 0.5rem;
  background: #1f2937;
  color: #fff;
  font-size: 0.75rem;
  font-weight: 500;
  white-space: nowrap;
  border-radius: 0.25rem;
  pointer-events: none;
  opacity: 0;
  transition: opacity 150ms ease;
  z-index: 10;
}

/* Arrow pointing down */
.tooltip-wrap [role="tooltip"]::after {
  content: "";
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 5px solid transparent;
  border-top-color: #1f2937;
}

/* Show on hover AND focus-within (SC 1.4.13) */
.tooltip-wrap:hover [role="tooltip"],
.tooltip-wrap:focus-within [role="tooltip"] {
  opacity: 1;
}

/* Hidden state set by Escape key (JS) */
.tooltip-wrap [role="tooltip"].tooltip-hidden {
  opacity: 0 !important;
}

/* SC 2.4.13: visible focus indicator */
.tooltip-wrap button:focus-visible,
.tooltip-wrap [tabindex="0"]:focus-visible {
  outline: 2px solid #5b2a86;
  outline-offset: 2px;
  border-radius: 0.25rem;
}

/* SC 2.5.8: minimum target size */
.tooltip-wrap button {
  min-width: 2.75rem;
  min-height: 2.75rem;
}

/* Toggletip bubble */
.toggletip-wrap [role="status"] {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translateX(-50%);
  padding: 0.5rem 0.75rem;
  background: #fff;
  border: 2px solid #d1d5db;
  border-radius: 0.5rem;
  font-size: 0.875rem;
  width: 240px;
  box-shadow: 0 4px 12px rgba(0,0,0,0.1);
  z-index: 10;
}

.toggletip-wrap [role="status"][hidden] {
  display: none;
}

/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
  .tooltip-wrap [role="tooltip"] {
    transition: none;
  }
}
JavaScript
// Escape key dismisses all visible tooltips
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') {
    document.querySelectorAll('[role="tooltip"]')
      .forEach(tip => tip.classList.add('tooltip-hidden'));
    // Also close any open toggletips
    document.querySelectorAll(
      '.toggletip-wrap [role="status"]'
    ).forEach(bubble => {
      bubble.hidden = true;
    });
  }
});

// Remove the hidden class when trigger is
// hovered or focused again
document.querySelectorAll('.tooltip-wrap')
  .forEach(wrap => {
    const tip = wrap.querySelector('[role="tooltip"]');
    function show() {
      tip.classList.remove('tooltip-hidden');
    }
    wrap.addEventListener('mouseenter', show);
    wrap.addEventListener('focusin', show);
  });

// Toggletip: click to show/hide
document.querySelectorAll('.toggletip-wrap')
  .forEach(wrap => {
    const btn = wrap.querySelector('button');
    const bubble = wrap.querySelector('[role="status"]');

    btn.addEventListener('click', () => {
      bubble.hidden = !bubble.hidden;
    });

    // Close when clicking outside
    document.addEventListener('click', (e) => {
      if (!wrap.contains(e.target)) {
        bubble.hidden = true;
      }
    });
  });
Why role="status" on the toggletip? The role="status" implicitly sets aria-live="polite", so screen readers announce the content when it appears without interrupting the current speech. This is better than role="tooltip" for the toggletip because the content is triggered by an explicit user action (click), not by hover/focus.

// 03 · why these decisions

Why These Decisions

Why role="tooltip"

The role="tooltip" tells assistive technology that the popup element contains supplementary description text for its trigger. Without this role, a screen reader sees a generic <span> and has no way to know it functions as a tooltip. The role also establishes the expected interaction pattern: the content is informational, not interactive, and is tied to the trigger element.

Why aria-describedby instead of aria-labelledby

A tooltip supplements the trigger's existing accessible name — it does not replace it. aria-describedby adds extra description that screen readers announce after the name and role. If you used aria-labelledby instead, the tooltip text would override the trigger's accessible name. For example, a "Settings" button with an aria-labelledby tooltip would lose its "Settings" label entirely.

Why show on both hover and focus

WCAG SC 1.4.13 (Content on Hover or Focus) explicitly requires that content triggered by pointer hover must also be triggerable by keyboard focus. Showing the tooltip only on hover locks out keyboard users entirely. Using CSS :hover and :focus-within together ensures both input methods work without any JavaScript for the basic show/hide behavior.

Why Escape to dismiss

WCAG SC 1.4.13 has three requirements for hover/focus content: it must be dismissable, hoverable, and persistent. The Escape key satisfies the dismissable requirement — the user can hide the tooltip without moving the pointer or focus away from the trigger. This is critical when a tooltip obscures other content the user needs to read.

Why not the title attribute

The title attribute is the native HTML tooltip, but it has serious accessibility problems. It cannot be triggered by keyboard focus in most browsers. It cannot be dismissed once shown. It cannot be styled, so it often has poor contrast or gets truncated for long text. Many screen readers ignore it entirely when an accessible name is already present. The WAI explicitly advises against relying on title for important information.

Why the toggletip is a separate pattern

Standard tooltips are for short, supplementary text — they appear on hover/focus and use aria-describedby. Toggletips are for longer descriptions or content that requires deliberate action to read. They use a button trigger with click activation and an aria-live region to announce the content. Mixing these patterns (e.g., putting interactive content in a tooltip) creates confusion for screen reader users who expect tooltips to be passive text.

// 04 · keyboard interaction

Keyboard Interaction

Key Action
Tab Moves focus to the tooltip trigger, showing the tooltip
Escape Dismisses the tooltip without moving focus away from the trigger
Tab (away) Moves focus to the next focusable element, hiding the tooltip
Enter / Space For toggletip: activates the toggle button, showing or hiding the popup content
Tooltip vs. toggletip keyboard behavior Standard tooltips appear automatically on focus and disappear when focus leaves. Toggletips require explicit activation with Enter or Space because they contain more substantial content that the user may want to read at their own pace. Both are dismissable with Escape.

// 05 · wcag 2.2 success criteria

WCAG 2.2 Success Criteria

This pattern satisfies the following WCAG 2.2 success criteria:

  • 1.4.13 Content on Hover or Focus Level AA — Tooltip appears on hover and focus, remains visible while the pointer is over the tooltip, and can be dismissed with Escape without moving focus.
  • 2.1.1 Keyboard Level A — Tooltip is accessible via keyboard focus on the trigger. Toggletip activates with Enter or Space.
  • 1.3.1 Info and Relationships Level Aaria-describedby programmatically links the tooltip text to its trigger element, so the relationship is exposed to assistive technology.
  • 2.4.13 Focus Appearance Level AAA — Trigger elements have a visible 2px solid focus indicator with sufficient contrast ratio.
  • 2.5.8 Target Size (Minimum) Level AA — Button triggers meet the 24x24px minimum target size via min-width and min-height of 2.75rem.
  • 4.1.2 Name, Role, Value Level Arole="tooltip" identifies the popup's purpose. The trigger's accessible name and description are programmatically determinable.
  • 2.4.11 Focus Not Obscured (Minimum) Level AA — Tooltip is positioned above the trigger so it does not cover other focused elements or the trigger itself.

// 06 · screen reader behavior

Screen Reader Behavior

When focusing the icon button (tooltip)

  • NVDA: "Settings, button" — announces the accessible name and role, then after a brief pause — "Settings" — reads the aria-describedby content as description
  • JAWS: "Settings, button, Settings" — announces the button label, then the tooltip description
  • VoiceOver: "Settings, button" — announces the label and role, then "Settings" as the description

When focusing the abbreviation (tooltip)

  • NVDA: "WCAG" — announces the text content, then "Web Content Accessibility Guidelines" — reads the description from aria-describedby
  • JAWS: "WCAG, Web Content Accessibility Guidelines" — announces the text and its description together
  • VoiceOver: "WCAG" — announces the text, then "Web Content Accessibility Guidelines" as description

When activating the toggletip

  • NVDA: Announces the live region content politely after the button activation — "ARIA roles tell assistive technology what an element does..."
  • JAWS: Announces the status content when it appears — reads the full text of the live region
  • VoiceOver: "More info about ARIA roles, button" on focus, then announces the live region content when the popup becomes visible

Description timing

Screen readers typically announce aria-describedby content after the element's name and role, often with a brief pause. Some screen readers (like NVDA) allow users to configure whether descriptions are read automatically or only on demand with a keyboard shortcut. This is why aria-describedby is preferred for supplementary information — it doesn't interfere with the primary label announcement.

// 07 · common mistakes

Common Mistakes

Using only the title attribute The title attribute tooltip is unreliable across browsers and assistive technology. It cannot be triggered by keyboard focus, cannot be dismissed, cannot be styled, is often truncated for longer text, and many screen readers skip it when other accessible name sources exist. Use role="tooltip" with aria-describedby instead.
Not showing tooltip on focus If the tooltip only appears on mouse hover, keyboard users cannot see it at all. WCAG SC 1.4.13 requires that hover-triggered content also appears on keyboard focus. Use CSS :focus-within on the tooltip wrapper or :focus on the trigger to ensure keyboard parity.
No Escape key dismissal WCAG SC 1.4.13 requires that additional content triggered by hover or focus can be dismissed without moving the pointer or focus. The Escape key is the standard mechanism. Without it, a tooltip that covers nearby content traps the user — they cannot dismiss it without leaving the trigger.
Tooltip covers the trigger or nearby content If the tooltip popup obscures the trigger element itself or other nearby focusable elements, it violates SC 2.4.11 (Focus Not Obscured). Position the tooltip above, below, or to the side of the trigger so that the trigger and surrounding content remain visible.
Using aria-labelledby instead of aria-describedby aria-labelledby overrides the element's accessible name entirely. If a button has aria-label="Settings" and you also add aria-labelledby pointing to a tooltip, the tooltip text replaces "Settings" as the name. Use aria-describedby to add supplementary information without changing the name.
Putting interactive content in a tooltip Tooltips should contain only plain text. If you need links, buttons, or other interactive elements in the popup, use a toggletip, popover, or dialog instead. Users cannot interact with tooltip content because it disappears when focus moves away from the trigger — making any interactive elements inside unreachable by keyboard.

// 08 · native html vs. aria

Native HTML vs. ARIA

HTML provides the title attribute as a native tooltip, but it falls short of accessibility requirements. This table compares the native approach with the ARIA tooltip pattern.

Feature Native HTML (title) ARIA (role="tooltip")
Tooltip element title attribute (browser-rendered popup) <span role="tooltip"> with aria-describedby
Keyboard trigger None — title does not show on focus in most browsers CSS :focus-within shows tooltip when trigger receives focus
Dismiss mechanism None — cannot be dismissed without moving the pointer Escape key via JavaScript hides the tooltip
Styling None — browser default, cannot be customized Full CSS control over colors, positioning, animation, and arrow
Hover persistence Browser-controlled — often disappears after a timeout CSS :hover keeps tooltip visible while pointer is over trigger
Screen reader support Inconsistent — often ignored when aria-label is present aria-describedby reliably announces tooltip content
Long text handling Often truncated by the browser Full text always shown, line wrapping controlled by CSS
Mobile support No hover on touch — title is typically invisible Can add touch handlers or use toggletip variant for mobile
The verdict The title attribute fails nearly every WCAG requirement for tooltip content. Use role="tooltip" with aria-describedby for accessible tooltips, and a toggletip with role="status" for longer or dynamic content. The native title can be added as a progressive enhancement but should never be the only tooltip mechanism.