---
title: Tooltips
source: 'https://academia.sh/en/courses/accessible-patterns/tooltips'
course: 'Accessible Component Patterns'
language: en
updated: '2026-08-19T05:19:52+00:00'
license: 'CC BY-SA 4.0'
---

# Tooltips

The specification of the tooltip; the distinction between the tooltip and the toggletip, a rule check of the 1.4.13 criterion's three conditions, computing whether the pointer can reach the tooltip by geometry, and the solution for touch access.

The modal dialog took over focus deliberately; the user opened it by pressing a button and
closed it with Escape. The tooltip is the opposite kind of component: it appears without
the user asking for it, does not take focus, lasts briefly, and in most setups works only
with a pointer.

This spontaneity makes it the most often misbuilt component. A setup that works with the
mouse never opens with the keyboard at all; on a touchscreen there is no such thing as
hovering, so the tooltip never appears; and text inside a layer that vanishes on its own
goes unread. This lesson ties the tooltip to three conditions and computes one of those
conditions by geometry.

## What It Solves, When Not to Use It

A **tooltip** is a layer that adds a **supplementary description** to a control that
already has a name. The icon button next to the "return date" heading in the borrowing
row of the catalog interface is an example: the heading is understandable on its own, and
the tooltip adds how the date is calculated.

Two rules follow from this definition. First, **the information in a tooltip is never
required**; the task must be completable without it. Second, **a tooltip cannot be a
control's name**; a button whose name exists only in its tooltip is nameless whenever the
tooltip is not visible.

Situations where it should not be used:

- **Interactive content.** A layer containing a link, button, or input is not a tooltip;
  focus needs to be able to reach that layer, and the moment it does, the tooltip's
  contract breaks.
- **Long text.** A description of several sentences belongs to a persistent help text
  standing next to the field; it cannot be read inside a layer that can disappear.
- **Error message.** An error must be persistent and associated with the field; an error
  message that appears on hover disappears before it is seen.

The counterpart pattern is the **toggletip**: it opens with a real button, works by
clicking, is announced because its content is written into a live region, and stays until
the user closes it. The distinction is in the trigger — a layer opened by hover and focus
is a tooltip, a layer opened by clicking is a toggletip. Because there is no hovering on a
touchscreen, the toggletip is the correct pattern everywhere the information is also
required by touch.

## Native Element First, ARIA Second

Markup has an attribute set aside for this purpose: `title`. By definition it carries
**advisory** information, and how it is presented is left to the user agent. Because
presentation is not left to the author, exactly the things the criterion demands of the
author — how long it stays visible, whether it can be dismissed, whether it can be
hovered onto — become impossible to control. For this reason the tooltip is one of the
rare patterns where the native counterpart does not satisfy the specification, and it is
built by hand.

The manual setup requires four bindings:

| Part | Source |
|---|---|
| The layer's role | `role="tooltip"` |
| Binding to the control | `aria-describedby` on the trigger, pointing to the tooltip box's id |
| The control's name | independent of the tooltip; given on the icon button with `aria-label` |
| Visibility | absent from the accessibility tree while the layer is hidden |

The distinction between name and description is critical here. The tooltip of a button
carrying only an icon most often shows the same text as the button's name. In this case
the text is a name, not a description, and binding it with `aria-describedby` produces a
second copy that is not added to the name; the correct setup binds the text to the name
with `aria-labelledby` and gives no separate description.

Whether the visible text is contained in the name is bound to a separate criterion:
**2.5.3 Label in Name**. Someone using voice control speaks the word they read on screen;
if the name does not contain that word, the control cannot be invoked. A button whose
tooltip reads "Extension terms" while its name is "Help" fails this criterion.

## Keyboard and Touch Contract

| Input | Behavior |
|---|---|
| Focus | The tooltip appears when the trigger gains focus, and hides when focus leaves |
| Pointer | Appears on hover; the pointer can move onto the tooltip itself |
| Escape | Hides the tooltip; focus stays on the trigger |
| Touch | The tooltip does not appear; the toggletip pattern is used if the information is required |

The tooltip is not reached with the tab key and focus is never placed inside it. The focus
order stops at the trigger; the tooltip only becomes visible.

The delay runs in two directions. The opening delay keeps the pointer from opening a
series of tooltips one after another while it passes over a row of elements. The closing
delay keeps the tooltip from vanishing while the pointer sits in the gap between the
trigger and the tooltip on its way from one to the other. The second delay is not a
preference; it is what the criterion requires.

## Checking the Three Conditions and the Geometry

The **1.4.13 Content on Hover or Focus** criterion sets three conditions: the content must
be **dismissible** (able to be hidden without moving the pointer or focus), must be
**hoverable** (the pointer must be able to move onto the content without it disappearing),
and must be **persistent** (it must stay until the hover, focus, or dismissal ends). The
three are checked separately; if one is missing, the criterion is not met.

The third condition depends on geometry: if the pointer falls into a "not hovering" state
in the gap while moving from the trigger to the tooltip, the tooltip disappears. The
script below first checks the three conditions as rules, then scans two paths the pointer
might follow and measures how many pixels of each fall outside the layers.

```js
// tooltip.mjs — the tooltip's three conditions and whether the pointer can reach it

// --- (a) rule check of 1.4.13's three conditions -----------------------------
// dismissible: the escape key hides the content
// persistent: no automatic hiding (autoHideMs === null)
// hoverable: content does not hide while the pointer moves from the trigger to the tooltip
const STRUCTURES = [
  { name: "hover only", escapeCloses: false, autoHideMs: 2000, transitArea: false },
  { name: "focus + hover", escapeCloses: false, autoHideMs: null, transitArea: false },
  { name: "closes with escape", escapeCloses: true, autoHideMs: 5000, transitArea: true },
  { name: "full specification", escapeCloses: true, autoHideMs: null, transitArea: true },
];

console.log("structure            dismissible  persistent  hoverable  1.4.13");
for (const y of STRUCTURES) {
  const dismissible = y.escapeCloses;
  const persistent = y.autoHideMs === null;
  const hoverable = y.transitArea;
  console.log(
    `${y.name.padEnd(19)} ${(dismissible ? "yes" : "no").padStart(13)} ` +
      `${(persistent ? "yes" : "no").padStart(9)} ${(hoverable ? "yes" : "no").padStart(11)}  ` +
      `${dismissible && persistent && hoverable ? "passes" : "FAILS"}`,
  );
}

// --- (b) Whether the pointer can reach the tooltip ---------------------------
// Trigger: a 24 x 24 pixel icon button (the "what does this mean" button at the end of the row).
// Tooltip box: 180 x 36.
const TRIGGER = { x: 620, y: 300, w: 24, h: 24 };
const TOOLTIP = { w: 180, h: 36 };

const inside = (n, k) => n.x >= k.x && n.x <= k.x + k.w && n.y >= k.y && n.y <= k.y + k.h;
const overlap = (a, b) => Math.max(0, Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x));

// Scan a broken path in 0.5 px steps; return the length that falls outside the union of the boxes.
function brokenLength(points, boxes) {
  let broken = 0;
  for (let p = 0; p < points.length - 1; p++) {
    const [a, b] = [points[p], points[p + 1]];
    const length = Math.hypot(b.x - a.x, b.y - a.y);
    if (length === 0) continue;
    const steps = Math.ceil(length / 0.5);
    let outside = 0;
    for (let i = 0; i <= steps; i++) {
      const n = { x: a.x + ((b.x - a.x) * i) / steps, y: a.y + ((b.y - a.y) * i) / steps };
      if (!boxes.some((k) => inside(n, k))) outside++;
    }
    broken += (outside * length) / steps;
  }
  return broken;
}

function placement({ side, align, gap, bridge }) {
  const y = side === "bottom" ? TRIGGER.y + TRIGGER.h + gap : TRIGGER.y - gap - TOOLTIP.h;
  const x =
    align === "start" ? TRIGGER.x
    : align === "center" ? TRIGGER.x + TRIGGER.w / 2 - TOOLTIP.w / 2
    : TRIGGER.x + TRIGGER.w - TOOLTIP.w;
  const tooltip = { x, y, w: TOOLTIP.w, h: TOOLTIP.h };
  const boxes = [{ ...TRIGGER }];
  const shared = overlap(TRIGGER, tooltip);
  if (bridge && gap > 0 && shared > 0) {
    boxes.push({
      x: Math.max(TRIGGER.x, tooltip.x),
      y: side === "bottom" ? TRIGGER.y + TRIGGER.h : TRIGGER.y - gap,
      w: shared,
      h: gap,
    });
  }
  boxes.push(tooltip);
  return { tooltip, boxes, shared };
}

const center = (k) => ({ x: k.x + k.w / 2, y: k.y + k.h / 2 });

const SCENARIOS = [
  { name: "bottom / centered / 0 px",               side: "bottom", align: "center", gap: 0,  bridge: false },
  { name: "bottom / centered / 12 px",              side: "bottom", align: "center", gap: 12, bridge: false },
  { name: "bottom / centered / 12 px + bridge",     side: "bottom", align: "center", gap: 12, bridge: true  },
  { name: "bottom / start-aligned / 8 px + bridge", side: "bottom", align: "start",  gap: 8,  bridge: true },
  { name: "top / end-aligned / 8 px + bridge",      side: "top",    align: "end",    gap: 8,  bridge: true },
  { name: "bottom / shifted / 8 px + bridge",       side: "bottom", align: "shifted", gap: 8, bridge: true },
];

console.log("\nplacement                               horizontal overlap  elbow path  direct path  hoverable");
for (const s of SCENARIOS) {
  // "shifted": tooltip has overflowed to the right of the trigger (a viewport-edge correction bug)
  const adjusted = s.align === "shifted" ? { ...s, align: "start" } : s;
  let { tooltip, boxes, shared } = placement(adjusted);
  if (s.align === "shifted") {
    tooltip = { ...tooltip, x: TRIGGER.x + TRIGGER.w + 4 };
    shared = overlap(TRIGGER, tooltip);
    boxes = [{ ...TRIGGER }, tooltip];
  }
  const m = center(tooltip);
  const b = center(TRIGGER);
  const elbow = brokenLength([b, { x: b.x, y: m.y }, m], boxes);
  const direct = brokenLength([b, m], boxes);
  console.log(
    `${s.name.padEnd(39)} ${(shared.toFixed(0) + " px").padStart(17)} ` +
      `${(elbow.toFixed(1) + " px").padStart(11)} ${(direct.toFixed(1) + " px").padStart(13)}  ` +
      `${elbow === 0 ? "yes" : "NO"}`,
  );
}

// --- (c) Trigger target size --------------------------------------------------
console.log(`\ntrigger target: ${TRIGGER.w} x ${TRIGGER.h} px, 2.5.8 threshold 24 x 24: ` +
  `${TRIGGER.w >= 24 && TRIGGER.h >= 24 ? "passes" : "fails"}`);
```

```
structure            dismissible  persistent  hoverable  1.4.13
hover only                     no        no          no  FAILS
focus + hover                  no       yes          no  FAILS
closes with escape            yes        no         yes  FAILS
full specification            yes       yes         yes  passes

placement                               horizontal overlap  elbow path  direct path  hoverable
bottom / centered / 0 px                            24 px      0.0 px        0.0 px  yes
bottom / centered / 12 px                           24 px     11.5 px       11.5 px  NO
bottom / centered / 12 px + bridge                  24 px      0.0 px        0.0 px  yes
bottom / start-aligned / 8 px + bridge              24 px      0.0 px       32.4 px  yes
top / end-aligned / 8 px + bridge                   24 px      0.0 px       32.4 px  yes
bottom / shifted / 8 px + bridge                     0 px     42.0 px       46.3 px  NO

trigger target: 24 x 24 px, 2.5.8 threshold 24 x 24: passes
```

The first table shows that the conditions are independent. A tooltip that closes with
Escape but hides itself after five seconds is not **persistent**, and the criterion is
still not met. For a slow reader, five seconds is not a guarantee; it is a limit.

The second table gives the criterion's third condition in pixels. Because the path scan
is done in 0.5-pixel steps, results round down by about half a step; a 12-pixel gap shows
up as 11.5 pixels of breakage.

Three results follow. **Leaving a gap is a sufficient defect on its own:** a twelve-pixel
visual gap means the pointer falls into a "not hovering" state for twelve pixels. **An
invisible transit area closes the gap:** an area as wide as the horizontal overlap of the
two boxes, filling the gap, makes the elbow path unbroken. **When the horizontal overlap
drops to zero, no area helps:** in the last row the tooltip did not fit the edge of the
screen, so it was shifted to the right of the trigger, and the two boxes no longer share
a column; there is no elbow path left for the pointer to reach the tooltip.

The fourth and fifth rows pass the criterion but show thirty-two pixels of breakage on
the direct path. This is not a conformance defect but a usability cost: a user aiming
straight at the center of the tooltip strays outside the trigger. **Centering** the
tooltip on the trigger reduces this cost to zero; the criterion only requires that some
path exist, while the design also wants the natural path to work.

## Measurable Constraints

**1.4.13** requires all three conditions together; the table checks the three as separate
columns.

**2.5.8 Target Size (Minimum).** The icon trigger must meet the 24 x 24 pixel threshold; a
16-pixel icon is expanded to this size with a surrounding hit area.

**1.4.3 Contrast (Minimum)** and **1.4.11 Non-Text Contrast.** The tooltip box's text
must meet a 4.5:1 ratio against its background, and the box's border, along with the
arrow's surroundings if there is one, must meet a 3:1 ratio against the surface. Because
the layer falls on top of the content beneath it, the tooltip's background cannot be
transparent.

**1.4.4 Resize Text.** When text is doubled in size, the tooltip box must grow without
clipping its content; a tooltip with a fixed height cuts off the text at this threshold.

**1.4.12 Text Spacing.** When line height and letter spacing are increased, the box still
must not overflow. Together, the two criteria require the tooltip box to be a box that
grows with its content.

## Common Mistakes and How to Recognize Them

**The tooltip only opens on hover.** A keyboard user can never reach the information. How
to recognize it: tab to the trigger and check whether the tooltip appears.

**The tooltip is the button's only name.** If the icon button has no `aria-label` value
and the name exists only in the tooltip, the button is nameless in the accessibility tree.
How to recognize it: read the button's name field without ever opening the tooltip;
automated checkers catch this defect with an existence rule.

**The tooltip contains a link.** The user has to move the pointer onto the tooltip to
reach the link, and a keyboard user can never reach it at all. How to recognize it: look
for a focusable element inside the tooltip content; if one is found, the wrong pattern was
chosen.

**Timed hiding.** If the tooltip vanishes on its own after a few seconds, the criterion's
persistence condition is not met. How to recognize it: open the tooltip and wait; a
tooltip that disappears is defective.

## Summary

- A tooltip adds a supplementary description to a control that already has a name; the
  information inside it can never be required, and a tooltip cannot stand in for a
  control's name.
- Interactive content, long text, and error messages do not belong in a tooltip; the
  toggletip, which opens by clicking and stays until the user closes it, is a separate
  pattern for this work.
- Because the presentation of the `title` attribute is left to the user agent, the
  control the criterion demands does not stay with the author; the tooltip is built by
  hand and carries a `role="tooltip"` role along with an `aria-describedby` binding.
- 1.4.13 requires three independent conditions: dismissible, hoverable, persistent. A
  tooltip that closes with Escape but hides itself after a timer does not meet the
  criterion.
- Hoverability is computed by geometry: the gap between the trigger and the tooltip must
  be closed with an invisible transit area, and horizontal overlap must not drop to zero.
- Centering the tooltip on the trigger is an extra decision the criterion does not require
  but the natural pointer path calls for.

## Next Step

The tooltip appeared because the user turned toward an element; the user decided when it
would be visible. The next layer severs even that link: the system sends its own
notification whenever something happens, no matter where on the screen the user is
looking. Three new questions arise here — how long should a notification stay on screen,
how should two notifications that arrive at the same time queue up, and how should a
notification reach beyond the visual channel? The next lesson computes how long content
stays visible from the text's reading speed and tests the announcement queue against
notifications that arrive one after another.
