Accessibility Developer Kit · Guide 17 / 20

Accessible Tooltip

Short text hints linked via aria-describedby, visible on hover AND focus, dismissable with Escape, and hoverable so users can move onto them. Covers the titleanti-pattern and the tooltip-vs-popover distinction — Vanilla & React.

  • WCAG: 1.4.13 · 2.1.1 · 4.1.2
  • Level: Intermediate
  • Icons: lucide-react (HelpCircle, Info)
1

What we are building

A text hint attached to a trigger, keyboard-friendly, dismissable per WCAG 1.4.13.

What we are building

A tooltip is a short, text-only description attached to a trigger (usually a button or icon). It appears on both hover AND focus, is linked via aria-describedby so screen readers announce it after the trigger label, and must be dismissable, hoverable, and persistent per WCAG 1.4.13.

If your “tooltip” contains links or buttons, it’s not a tooltip — it’s a popover. Use role="dialog" instead. See Variant C for the comparison.

2

The 4 real-world variants

Switch between the inaccessible version and the accessible fix.

The 4 real-world variants

Variant A

Standard tooltip

Preferred
Live example

Hover or Tab to the ?. Esc dismisses. Pure text — no links or buttons inside.

HTML · Accessible
<!-- aria-describedby + role="tooltip"; shows on hover AND focus -->
<button
  type="button"
  aria-label="More info about promo codes"
  aria-describedby="pc-tip"
>?</button>

<span role="tooltip" id="pc-tip" class="tooltip" hidden>
  Enter a promo code to apply a discount at checkout.
</span>

<script>
  // Show on mouseenter + focus, hide on mouseleave + blur
  // Escape also dismisses (WCAG 1.4.13)
</script>

aria-describedby + role=tooltip. Appears on hover AND focus. Text only — no links or buttons inside.

Variant B

title attribute (anti-pattern)

Critical fix
Live example

title="..." fails on touch and has inconsistent SR support. Build a real tooltip.

HTML · Accessible
<!-- Real tooltip with aria-describedby + visible styled bubble -->
<span class="tip-wrap">
  <button
    type="button"
    aria-describedby="exp-tip"
    onmouseenter="show('exp-tip')"
    onmouseleave="hide('exp-tip')"
    onfocus="show('exp-tip')"
    onblur="hide('exp-tip')"
  >Export</button>
  <span role="tooltip" id="exp-tip" hidden>
    Export the current view as a CSV file
  </span>
</span>

title='...' fails on touch, has inconsistent SR support, and can't be styled. Build a real tooltip instead.

Variant C

Tooltip vs popover

Pick correctly
Live example
Tooltip (role=tooltip)
Popover (role=dialog)

Tooltip = text only (hover/focus). Popover = actions inside (click to open, Esc to close).

HTML · Accessible
<!-- Interactive content → use role="dialog" (a popover) -->
<button
  type="button"
  aria-haspopup="dialog"
  aria-expanded="false"
  aria-controls="account-menu"
  aria-label="Account menu"
>Account</button>

<div
  id="account-menu"
  role="dialog"
  aria-labelledby="menu-title"
  hidden
>
  <h4 id="menu-title">Account</h4>
  <ul>
    <li><a href="/profile">Profile</a></li>
    <li><a href="/billing">Billing</a></li>
    <li><button type="button">Sign out</button></li>
  </ul>
</div>

<!-- JS: click to open, Esc to close, focus returns to trigger -->

If it has interactive content (links / buttons), it's a popover (role=dialog). Tooltips are text-only.

Variant D

Dismissable / Hoverable / Persistent

WCAG 1.4.13
Live example

Why do we ask for your phone?

Try moving the mouse from the ? onto the tooltip — it stays open (hoverable).Esc dismisses even while hovering.

JAVASCRIPT · Accessible
// WCAG 1.4.13: dismissable + hoverable + persistent
let closeTimer;
function scheduleClose() {
  clearTimeout(closeTimer);
  closeTimer = setTimeout(() => hide(), 120);   // tiny grace period
}
function cancelClose() { clearTimeout(closeTimer); }

btn.addEventListener('mouseenter', () => { cancelClose(); show(); });
btn.addEventListener('mouseleave', scheduleClose);
btn.addEventListener('focus',      () => { cancelClose(); show(); });
btn.addEventListener('blur',       scheduleClose);

// Hoverable: moving the mouse onto the tooltip itself keeps it open
tip.addEventListener('mouseenter', cancelClose);
tip.addEventListener('mouseleave', scheduleClose);

// Dismissable: Escape closes without moving the pointer
document.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') hide();
});

Grace period on close so users can move onto the tooltip; Escape dismisses even while hovering.

3

Flagship implementation

A reusable AccessibleTooltip with full WCAG 1.4.13 support.

Flagship implementation

AccessibleTooltip.jsx
import React, { useEffect, useId, useRef, useState } from "react";
import "./AccessibleTooltip.css";

/**
 * AccessibleTooltip
 *   - label:   accessible name of the trigger button (string)
 *   - content: text content of the tooltip (string — no interactive content!)
 *   - icon:    optional lucide-react icon component for the trigger
 */
export default function AccessibleTooltip({ label, content, icon: Icon }) {
  const id = useId();
  const [open, setOpen] = useState(false);
  const btnRef = useRef(null);
  const timerRef = useRef(null);

  function cancel() { clearTimeout(timerRef.current); }
  function scheduleClose() {
    cancel();
    timerRef.current = setTimeout(() => setOpen(false), 120);
  }

  useEffect(() => {
    if (!open) return;
    function onKey(e) {
      if (e.key === "Escape") {
        e.preventDefault();
        setOpen(false);
      }
    }
    document.addEventListener("keydown", onKey);
    return () => document.removeEventListener("keydown", onKey);
  }, [open]);

  useEffect(() => () => cancel(), []);

  return (
    <span className="tip-wrap">
      <button
        ref={btnRef}
        type="button"
        aria-label={label}
        aria-describedby={open ? id : undefined}
        className="help-btn"
        onMouseEnter={() => { cancel(); setOpen(true); }}
        onMouseLeave={scheduleClose}
        onFocus={() => { cancel(); setOpen(true); }}
        onBlur={scheduleClose}
      >
        {Icon ? <Icon aria-hidden="true" size={14} /> : "?"}
      </button>
      {open && (
        <span
          role="tooltip"
          id={id}
          className="tooltip"
          onMouseEnter={cancel}
          onMouseLeave={scheduleClose}
        >
          {content}
        </span>
      )}
    </span>
  );
}
Usage · Form.jsx
import { HelpCircle } from "lucide-react";
import AccessibleTooltip from "./AccessibleTooltip";

export default function Form() {
  return (
    <label htmlFor="promo">
      Promo code
      <AccessibleTooltip
        label="More info about promo codes"
        content="Enter a promo code to apply a discount at checkout."
        icon={HelpCircle}
      />
      <input id="promo" type="text" />
    </label>
  );
}
4

Keyboard behavior

Keyboard users need the hint too — show on focus, dismiss with Escape.

Keyboard behavior

ActionExpected result
Tab to triggerTooltip appears (same as hover)
BlurTooltip disappears after a short grace period
EscDismisses tooltip without moving pointer / focus
Mouse onto tooltipTooltip stays open (hoverable)
5

Screen reader expectations

Tooltip text is announced as the trigger's description.

Screen reader expectations

  • Trigger focused: “More info about promo codes, button, Enter a promo code to apply a discount at checkout”
  • Trigger with icon: “Help, button, Important hint”
  • Note: role="tooltip" alone doesn’t make SR announce the text — the aria-describedby link does the work.
6

WCAG mapping

Why each rule matters here.

WCAG mapping

Success CriterionWhy it matters
1.4.13 Content on Hover / Focus (AA)Tooltip must be dismissable (Esc), hoverable, and persistent until dismissed or focus moves.
2.1.1 Keyboard (A)Must appear on focus, not just hover.
4.1.2 Name, Role, Value (A)Trigger exposes its label; tooltip linked via aria-describedby.
1.4.11 Non-text Contrast (AA)Tooltip background vs. page must have 3:1 contrast at the edges.
2.5.5 Target Size (AA, 2.2)Trigger button must be at least 24×24 px.
7

Testing approach

Framework-agnostic patterns. Pick the one that matches your stack.

Testing approach

Pattern: focus trigger → assert role=tooltip appears → assert aria-describedby → Escape → assert tooltip gone.

Jest + Testing Library + jest-axe

AccessibleTooltip.test.jsx
// Jest + @testing-library/react + jest-axe
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { axe, toHaveNoViolations } from "jest-axe";
import AccessibleTooltip from "./AccessibleTooltip";

expect.extend(toHaveNoViolations);

test("tooltip appears on focus and is linked via aria-describedby", async () => {
  const user = userEvent.setup();
  render(
    <AccessibleTooltip label="Help" content="Important hint" />
  );
  const btn = screen.getByRole("button", { name: "Help" });
  btn.focus();
  const tip = await screen.findByRole("tooltip");
  expect(tip).toHaveTextContent("Important hint");
  expect(btn).toHaveAccessibleDescription("Important hint");
});

test("Escape dismisses the tooltip", async () => {
  const user = userEvent.setup();
  render(<AccessibleTooltip label="Help" content="Hint" />);
  const btn = screen.getByRole("button", { name: "Help" });
  btn.focus();
  await screen.findByRole("tooltip");
  await user.keyboard("{Escape}");
  expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

test("has no axe violations", async () => {
  const { container } = render(
    <AccessibleTooltip label="Help" content="Hint" />
  );
  expect(await axe(container)).toHaveNoViolations();
});

Vitest + Testing Library

AccessibleTooltip.test.jsx
// Vitest + @testing-library/react
import { expect, test } from "vitest";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import AccessibleTooltip from "./AccessibleTooltip";

test("hover shows tooltip, mouseleave hides after grace period", async () => {
  const user = userEvent.setup();
  render(<AccessibleTooltip label="Help" content="Hint" />);
  const btn = screen.getByRole("button", { name: "Help" });
  await user.hover(btn);
  expect(await screen.findByRole("tooltip")).toBeInTheDocument();
  await user.unhover(btn);
  // Give the grace period a chance to fire
  await new Promise(r => setTimeout(r, 160));
  expect(screen.queryByRole("tooltip")).not.toBeInTheDocument();
});

Playwright + @axe-core/playwright

tooltip.spec.js
// Playwright + @axe-core/playwright
import { test, expect } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";

test("tooltip page has no serious a11y violations", async ({ page }) => {
  await page.goto("/en/accessible-tooltip");
  const results = await new AxeBuilder({ page })
    .withTags(["wcag2a", "wcag2aa", "wcag22aa"])
    .analyze();
  const serious = results.violations.filter(
    (v) => v.impact === "critical" || v.impact === "serious"
  );
  expect(serious).toEqual([]);
});

test("WCAG 1.4.13: Escape dismisses the tooltip", async ({ page }) => {
  await page.goto("/en/accessible-tooltip");
  const trigger = page.getByRole("button", { name: /more info/i }).first();
  await trigger.focus();
  await expect(page.getByRole("tooltip").first()).toBeVisible();
  await page.keyboard.press("Escape");
  await expect(page.getByRole("tooltip").first()).toBeHidden();
});
8

Manual QA checklist

Tick each item before shipping.

Manual QA checklist

  • Tooltip linked to trigger via aria-describedby
  • Element has role="tooltip"
  • Shows on hover AND focus (not hover-only)
  • Escape dismisses without moving pointer
  • Users can move pointer onto the tooltip without it closing
  • Tooltip is pure text — no links, buttons, or form controls
  • No title="..." as the only tooltip source
  • Trigger has an accessible name (aria-label if icon-only)
  • Tooltip background has 3:1 contrast against surrounding content
  • Zero critical / serious axe violations
9

Common mistakes

The tooltip bugs we see on nearly every audit.

Common mistakes

  • Using title="...". Fails on touch, inconsistent SR support.
  • Hover-only (no focus). Keyboard users never see the hint.
  • Tooltip disappears when the mouse leaves the trigger. Fails 1.4.13 hoverable.
  • No Escape handler. Fails 1.4.13 dismissable.
  • Interactive content inside. You built a popover; use role="dialog".
  • Low-contrast tooltip. Dark-on-dark or light-on-light fails 1.4.11.
10

Download & next

Grab the code bundle, or jump to the next guide.

Download and next

© 2026 Aria Probe. All rights reserved
Powered by techtipsall Logo