Skip to content
academia.sh

Lesson 15 / 25

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.

Contents

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.

// 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.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close