Command Palette

Search for a command to run...

Hectal
PHASE 8Intermediate ~27 min· topic 2 of 7

Topic 8.2

Tooltips

In one line

A tooltip shows the exact numbers of one mark on demand. This topic builds one that stays on screen, opens by tap and by keyboard focus, shows missing values honestly, and never holds the only copy of a number.

Think of it like this

The label next to a painting in a museum. The painting stays clean, and the label gives details when you step close. But a label that appears only when you stand on one special floor tile would be missed by a visitor in a wheelchair and by anyone who cannot step. A good tooltip can be reached in more than one way.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

tooltip
A small box that shows the details of one mark when the reader points at it, taps it or focuses it.
flip
Moving the tooltip to the other side of the point when it would stick out.
clamp
Forcing a number into a range, here keeping the tooltip's left and top inside the container.
focus
The element that receives keyboard input. Tab moves it; a focused dot can show its tooltip.
direct label
Text written on the chart next to a mark, so the number is visible without any interaction.

Step by step

01A transparent overlay catches every move

Draw the year's line and put one transparent rectangle over the plot. The rectangle gets every pointermove, so the line's thin hit area (Topic 8.1) stops mattering.

02Write the numbers in an HTML box

Find the nearest day with a bisector (Topic 8.3), put a dot on it and add the tooltip: a div with the date and the rides, beside the dot. It uses textContent, and pointer-events: none so it never steals the pointer.

03Keep it on the screen

Add the flip and the clamp. In the first example the last days of December show it: the tooltip moves to the left of the dot instead of hanging off the right edge.

the three ruleswhole filejs
if (left + w > box.width) left = ux - w - 12; // no room on the right: go left
if (top < 0) top = uy + 12;                    // no room above: go below
left = Math.max(0, Math.min(left, box.width - w)); // last safety

04Open by tap and by focus too

The examples below add the other two ways in. Tap: pointerdown with pointerType === "touch" opens the tooltip and keeps it open. Focus: focus opens it, blur and Escape close it.

Open by tap and by focus toodiagram
Rendering diagram…

Try it yourself

  1. 1

    Prefer the left side

    In the first example, change let left = ux + 12 inside place to let left = ux - w - 12, and change the line if (left + w > box.width) left = ux - w - 12; to if (left < 0) left = ux + 12;. The July tooltip now sits left of its point (left: 192 instead of 371), December stays on the left, and only the January one flips to the right (left: 56). The rule is the same, mirrored.

  2. 2

    Treat 0 as missing, the classic bug

    In the third example, change Number.isFinite(v) ? v.toLocaleString to v ? v.toLocaleString. 0 now prints no data, although a day with zero rides is real data, and the string "910" now prints 910 rides, although it is not a number. null, undefined and NaN still print no data. Truthiness is the wrong test for numbers.

Code & diagrams

A tooltip that flips at the edges New tab

The three events are sent by the page, so the output never changes. In the editor, hover the chart for real.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

2025-01-01 | Wed 1 Jan 2025: 1,193 rides | tooltip left: 56 top: 134 | naive right edge: 219 of 640 | inside: true
2025-07-18 | Fri 18 Jul 2025: 4,380 rides | tooltip left: 371 top: 30 | naive right edge: 526 of 640 | inside: true
2025-12-31 | Wed 31 Dec 2025: 910 rides | tooltip left: 449 top: 148 | naive right edge: 799 of 640 | inside: true
Open by hover, by tap and by keyboard focus New tab

Twelve monthly dots are focusable. A line of 365 days would not be: see the build, where one focusable chart uses the arrow keys.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

start                        | hidden
Tab to Jan                   | shows Jan: 43,439 rides
Tab to Feb                   | shows Feb: 44,343 rides
Escape on Feb                | hidden
focus leaves                 | hidden
finger taps Jul              | shows Jul: 117,803 rides
finger lifts                 | shows Jul: 117,803 rides
finger taps Aug              | shows Aug: 108,806 rides
finger taps the empty chart  | hidden
mouse enters Sep             | shows Sep: 92,354 rides
mouse leaves                 | hidden
marks you can reach with Tab: 12
What goes into the tooltip: missing values, markup, long labels New tab

innerHTML turned part of the name into a real element. With data you do not control, that is how markup injection starts; textContent can never do it.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

1193       -> 1,193 rides
0          -> 0 rides
null       -> no data
undefined  -> no data
NaN        -> no data
"910"      -> no data
1000000000 -> 1,000,000,000 rides
innerHTML  : text shown: "Quay & co" | child elements created: 1
textContent: text shown: "<b>Quay</b> & co" | child elements created: 0
long label: width 236 px (220 plus 16 of padding), and the box grows taller: true
without max-width and wrapping: wider than 500 px: true

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Measure a hidden tooltip

In the first example, move tip.hidden = false; from before tip.textContent = … to after place(x(d.date), y(d.rides));.

terminal
$ Console
── what you'll see ──
2025-01-01 | Wed 1 Jan 2025: 1,193 rides | tooltip left: 56 top: 162 | naive right edge: 219 of 640 | inside: true
2025-07-18 | Fri 18 Jul 2025: 4,380 rides | tooltip left: 371 top: 30 | naive right edge: 526 of 640 | inside: true
2025-12-31 | Wed 31 Dec 2025: 910 rides | tooltip left: 449 top: 148 | naive right edge: 799 of 640 | inside: true

Break #2

Close the tooltip when the finger lifts

In the second example, change the pointerleave line to .on("pointerleave", (event) => { hide(); }), so it no longer checks the pointer type.

terminal
$ Console
── what you'll see ──
finger taps Jul | shows Jul: 117,803 rides
finger lifts | hidden
finger taps Aug | shows Aug: 108,806 rides

Myth vs fact

Myth

The title attribute is a tooltip.

Fact

A native title appears after a delay, cannot be styled, does not appear on touch and is not announced consistently. Use it only as a fallback.

Myth

Tooltips are accessible if they appear on hover.

Fact

Hover is the one input that keyboards, phones and screen readers do not have. Add focus and tap, and put the key numbers on the chart.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    For scaled SVGs, convert with the screen matrix: new DOMPoint(ux, uy).matrixTransform(svg.getScreenCTM()) gives client pixels (the examples use it to dispatch events), then subtract the wrapper's getBoundingClientRect() left and top.

  • ▸

    A tooltip is only announced if the mark points to it: the build gives the chart aria-describedby="rides-tip" while the tooltip is open, and the tooltip has role="tooltip". A div that merely appears is silent for a screen reader. WCAG 2.2 criterion 1.4.13 (Content on Hover or Focus) is level AA.

  • ▸

    Mouse and pen can hover, touch cannot, so test the handlers with pointerType !== "touch" for hover and === "touch" for tap. The pointerleave that a lifted finger sends is the classic way to make a tap-tooltip close at once (the second break-it).

Remember this

  1. 1

    The problem this solves: a line of 365 days has 365 numbers and no room to print them. A tooltip prints one on demand: the date, the value, maybe the weather. The chart stays readable and the detail is one move away.

  2. 2

    HTML or SVG? An SVG tooltip (a <text> on a <rect>) scales with the chart, but SVG text does not wrap, does not size its box by itself, and is clipped at the SVG's edge. An HTML tooltip (a <div> with position: absolute inside a position: relative wrapper) wraps, takes CSS, can hang outside the SVG, and is read by screen readers like any text. Use HTML. If the SVG is scaled, convert chart units to CSS pixels first (multiply by svg.clientWidth / viewBoxWidth); the examples here draw at 1:1 so the numbers stay simple.

  3. 3

    Keep it on screen. Put the tooltip up and to the right of the point. If it would stick out on the right, put it on the left. If it would stick out on the top, put it below. Then clamp to the container as a last safety. You can only do this after measuring it: a tooltip with the hidden attribute (display: none) has width 0, so unhide it first, then measure. Give it pointer-events: none, or it sits under the pointer, steals the events and flickers.

  4. 4

    Keyboard focus. A tooltip that opens only on hover excludes keyboard users. Marks that carry a tooltip can take focus (tabindex="0"), open it on focus, close it on blur, and close on Escape. WCAG 2.2 criterion 1.4.13 asks that such content is dismissible (Escape), hoverable (the pointer can move onto it) and persistent (stays until dismissed). Never give 365 points tabindex="0": the Tab key would need 365 presses to leave the chart. For dense lines use one focusable chart and arrow keys (the build does this).

  5. 5

    Never let a tooltip hold the only copy. Screen readers, printouts, screenshots and phones without hover do not see tooltips. Important numbers (the peak, the latest value, the outage day) get a direct label on the chart, and the whole data gets a table (Topic 15.3). Fill the tooltip with textContent, never with innerHTML and data: a station named A <b> would become markup. A missing value shows "no data", not NaN and not 0.

Explain it without notes

01

Why measure the tooltip after unhiding it?

02

Why textContent and not innerHTML for a tooltip?

03

Name three ways a reader can reach a tooltip, and what event each uses.

Practice

01

Write place(ux, uy, w, h, boxW, boxH) as a pure function that returns { left, top } and test it on the points (10, 5), (620, 130) and (320, 130) with a 130 by 40 tooltip in a 640 by 260 box.

02

Add a direct label "Peak: 4,380" to the line chart so the highest day is readable without any interaction.

Trade-offs

  • ↔

    An HTML tooltip is flexible but needs unit conversion when the SVG is scaled. An SVG tooltip needs none but cannot wrap text.

Done when you can

  • I can place a tooltip so it never leaves its container, and say why it must be measured after unhiding.

  • I can open a tooltip by hover, by tap and by keyboard focus, and close it with Escape.

  • I can write tooltip text with textContent and show a missing value as no data.

  • I can name numbers that must also be on the chart or in a table, not only in a tooltip.