# Chart tooltips a keyboard can't open

Source: https://easeweb.dev/learn/chart-tooltips
Topics: Tables and data > Chart points, legends and tooltips work with a keyboard (WCAG 2.1.1, 4.1.2)
The fix: Either print each bar's count on the chart, with chartjs-plugin-datalabels, and keep the tooltip for the pointer; or make the canvas one Tab stop with role="application", whose arrow keys move Chart.js's tooltip from bar to bar and write each count to a status line.
Test with: keyboard, screen reader
References: https://www.w3.org/WAI/WCAG22/Understanding/keyboard.html https://www.w3.org/WAI/WCAG22/Techniques/failures/F54 https://www.w3.org/WAI/WCAG22/Understanding/content-on-hover-or-focus.html https://www.w3.org/TR/wai-aria-1.2/#application https://www.chartjs.org/docs/latest/configuration/tooltip.html https://www.chartjs.org/docs/latest/configuration/interactions.html https://www.chartjs.org/docs/latest/samples/advanced/programmatic-events.html https://chartjs-plugin-datalabels.netlify.app/guide/positioning.html https://github.com/recharts/recharts/releases/tag/v3.0.0

This is a recreated teaching example, not a finding about a named client. The code is a starting pattern: test it in your own product. No completed assistive-technology test is claimed here.

## What happens

A shop's dashboard shows last week's orders per day as a bar chart. The bars have no numbers on them: to read Saturday's count, you point at Saturday's bar, and a small dark tooltip opens over it, "Sat" and "Orders: 31". Move off the bar and it closes. Chart.js does this by default, with no code from the shop.

Put the mouse aside and press Tab, and focus goes from whatever is above the card straight to "View all orders" under the chart. Nothing on the chart takes focus, and no key opens a tooltip, so the counts never appear. A keyboard user can see that Saturday was the busiest day, but not that it had 31 orders, or whether Tuesday had 15 or 14; the gridlines every 5 orders are all they have to estimate from.

The chart does have a text alternative: its name holds every day's count, as the first fix in [charts whose numbers a screen reader can't read](/learn/chart-alternatives) writes it. A screen reader user hears the counts. A sighted keyboard user sees none of that text.

## Why it fails

WCAG 2.1.1 Keyboard asks that everything a page does with a pointer can also be done with a keyboard. Showing a bar's count is something this chart does, and only a pointer gets it to. Chart.js paints the tooltip onto the canvas, and opens it from the browser events in its `events` option, which by default are `mousemove`, `mouseout`, `click`, `touchstart` and `touchmove`, all of them from a pointer or a finger. Its default interaction opens the tooltip for the bar under the pointer only. A canvas takes no focus by default, and the chart listens for no key, so there is nothing a keyboard can do to it. That is failure F54: a function, here showing a value, wired only to pointer-specific event handlers.

The tooltip being the only place the count is shown is what makes it a failure. A tooltip that repeats a number already printed on the bar, or in a table beside the chart, adds nothing a keyboard user is missing.

## Who is affected

Exact numbers are what people open a dashboard for: Saturday's 31 against Friday's 24 is the difference between a good weekend and a busy one. A touch screen still works, because Chart.js listens to touches; it is the keyboard, and the tools that work through it, that get nothing.

## What automation and AI miss

The tooltip is pixels painted on the canvas while a pointer is over it, so there is nothing in the page for a checker to find. axe sees a canvas with `role="img"` and a name and passes it; it doesn't hover over anything. This audit raises a lead for every chart a library drew that holds nothing that takes focus, for a person to point at its bars and then try the same with the keyboard; it can't tell, from the page, whether a tooltip holds anything that isn't printed elsewhere. An AI assistant asked for a bar chart with tooltips may reach for the library's defaults, which give exactly this Before.

## Before: counts only in Chart.js's tooltip

```html
<section class="card">
  <h2>Orders per day</h2>
  <p>Last week</p>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <a href="/orders">View all orders</a>
</section>
```

```css
.card canvas {
  max-height: 200px;
}
```

```js
import Chart from 'chart.js/auto';

const canvas = document.getElementById('orders');
const chart = new Chart(canvas, {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'Orders', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: 'rgba(74, 134, 212, 0.55)', borderColor: '#4a86d4', borderWidth: 1 }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});
```

- The counts are drawn only in the tooltip, and the tooltip opens only while a pointer is on a bar.
- The canvas takes no focus, so Tab goes past the chart, and no key opens a tooltip.

## After: the counts printed on the bars

```html
<section class="card">
  <h2>Orders per day</h2>
  <p>Last week</p>
  <canvas id="orders" role="img" aria-label="…"></canvas>
  <a href="/orders">View all orders</a>
</section>
```

```css
.card canvas {
  max-height: 200px;
}
```

```js
import Chart from 'chart.js/auto';
import ChartDataLabels from 'chartjs-plugin-datalabels';

const canvas = document.getElementById('orders');
const chart = new Chart(canvas, {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'Orders', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: 'rgba(74, 134, 212, 0.55)', borderColor: '#4a86d4', borderWidth: 1 }
    ]
  },
  plugins: [ChartDataLabels],
  options: {
    layout: { padding: { top: 20 } },
    plugins: {
      legend: { display: false },
      datalabels: { anchor: 'end', align: 'end', color: '#1f2937' }
    }
  }
});
```

1. Add chartjs-plugin-datalabels to this chart, in its `plugins` list, so each bar's value is painted with it.
2. Set `anchor: 'end'` and `align: 'end'`, so each count sits just above the top of its bar, where the eye already looks.
3. Paint the counts in `#1f2937`, 14.68:1 against the white card, and give the chart 20 pixels of `layout.padding` at the top, so Saturday's 31 isn't cut off above the tallest bar.

This fixes the Before and passes 2.1.1: no count is left only in the tooltip, so there is nothing a keyboard has to open. The tooltip stays, for anyone pointing at a bar.

## After: arrow keys that move the tooltip

```html
<section class="card">
  <h2>Orders per day</h2>
  <p>Last week</p>
  <canvas id="orders" role="application" tabindex="0" aria-label="…" aria-describedby="orders-keys"></canvas>
  <p id="orders-keys" class="keys">Use the left and right arrow keys to read each day.</p>
  <p id="orders-day" class="visually-hidden" role="status"></p>
  <a href="/orders">View all orders</a>
</section>
```

```css
.card canvas {
  max-height: 200px;
}
.keys {
  margin: 4px 0 0;
  font-size: 13px;
  color: #4b5563;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}
```

```js
import Chart from 'chart.js/auto';

const canvas = document.getElementById('orders');
const chart = new Chart(canvas, {
  type: 'bar',
  data: {
    labels: ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'],
    datasets: [
      { label: 'Orders', data: [12, 15, 14, 18, 24, 31, 20], backgroundColor: 'rgba(74, 134, 212, 0.55)', borderColor: '#4a86d4', borderWidth: 1 }
    ]
  },
  options: { plugins: { legend: { display: false } } }
});

const status = document.getElementById('orders-day');
let day = -1;

function show(index) {
  day = index;
  const active = [{ datasetIndex: 0, index }];
  const bar = chart.getDatasetMeta(0).data[index];
  chart.setActiveElements(active);
  chart.tooltip.setActiveElements(active, { x: bar.x, y: bar.y });
  chart.update();
  status.textContent = `${chart.data.labels[index]}: ${chart.data.datasets[0].data[index]} orders`;
}

function hide() {
  chart.setActiveElements([]);
  chart.tooltip.setActiveElements([], { x: 0, y: 0 });
  chart.update();
  status.textContent = '';
}

canvas.addEventListener('focus', () => show(Math.max(day, 0)));
canvas.addEventListener('blur', hide);
canvas.addEventListener('keydown', (event) => {
  const last = chart.data.labels.length - 1;
  if (event.key === 'ArrowRight') show(Math.min(day + 1, last));
  else if (event.key === 'ArrowLeft') show(Math.max(day - 1, 0));
  else if (event.key === 'Home') show(0);
  else if (event.key === 'End') show(last);
  else if (event.key === 'Escape') hide();
  else return;
  event.preventDefault();
});
```

1. Put the canvas in the Tab order with `tabindex="0"`, so the chart is one Tab stop between the heading and the link.
2. Give it `role="application"`, keeping its name. A screen reader then passes the arrow keys to the page while the chart has focus, instead of using them to read on.
3. On focus, open the tooltip on the day the keys were last on, Monday the first time, with Chart.js's own `setActiveElements` on the chart and on `chart.tooltip`, the calls its "Programmatic Event Triggers" sample uses.
4. Move it with Left and Right Arrow, to the first and last day with Home and End, and hide it on Escape and when focus leaves, so the tooltip opened by a key closes without moving focus.
5. Say which keys to use, in a line under the chart that the canvas points at with `aria-describedby`, so a sighted keyboard user knows the chart can be stepped through.
6. Write each day's tooltip to a `role="status"` line, hidden from view and in the page from the start, so a screen reader reads "Sat: 31 orders" after each press.

This fixes the Before and passes 2.1.1: every count the pointer can bring up, the arrow keys can too. The chart keeps its clean drawing, and the tooltip still opens on hover.

## Which option to use

Both pass. Printing the counts is a plugin and four options, and it serves everyone at once: a keyboard user, someone magnifying the page, and anyone who never thinks to hover. It suits a chart with a handful of bars, like this one, and stops working when the labels crowd each other, with dozens of bars or several series side by side. The arrow keys keep the drawing as it was and work for any number of points, at the cost of a script, a line of instructions, and a role that has to be right; choose them for a dense chart, or one where printed numbers would bury the shape. If the chart already has the table of counts that [charts whose numbers a screen reader can't read](/learn/chart-alternatives) puts beside it, every value is already in text a keyboard reaches, and the tooltip is no longer the only place for them.

## Implementation decisions

**Why `role="application"` is right here, and rarely elsewhere.** `application` tells a screen reader to give the keys to the page, which takes its reading keys away. That is wrong for content that has something to read, and it is why the role is almost never the answer. A canvas has nothing in it to read: every key it needs, it handles, and every count it shows, it writes to the status line. Recharts 3 does the same by default, with `role="application"` and `tabindex="0"` on the chart, the arrow keys moving its tooltip, and the tooltip a live region; Recharts 2 left this off unless `accessibilityLayer` was set.

**The tooltip on focus is content on focus.** A tooltip that opens when the chart gets focus must close on Escape without focus moving, and stay open until it is dismissed or focus leaves, as WCAG 1.4.13 asks of content on hover or focus. How a tooltip should behave when it vanishes on approach or covers what is under it is covered in [tooltips that vanish on approach and can't be dismissed](/learn/hover-content).

**Keep the status line short.** It is read after every press, so give it the day and the count, and nothing that repeats the chart's name.

**Other libraries.** Highcharts' accessibility module adds keyboard navigation through the points. A chart drawn as SVG by hand, such as the D3 Graph Gallery's tooltip example, which shows a tooltip `div` on `mouseover` and hides it on `mouseout`, fails the same way, and the same two fixes apply: a `text` element over each mark, or one Tab stop whose arrow keys move between them.

Bars that filter the page or drill down when clicked are another form of the same failure, which no guide covers yet. A legend that hides a series only on click is covered in [chart legends a keyboard can't toggle](/learn/chart-legends).

## Verify the fix

1. In the code, find where each count is shown: confirm every value the tooltip shows is printed on the chart, or that the canvas has `tabindex="0"`, `role="application"` and a key handler that moves the tooltip.
2. Put the mouse aside and Tab through the card. With the counts printed, read each one. With the arrow keys, Tab stops on the chart, the tooltip opens, Left and Right Arrow reach every day, Home and End the first and last, Escape closes it, and Tab moves on to the link.
3. With a screen reader, Tab to the chart: its name is read, and with the arrow keys fix each press reads the day and its count, such as "Sat: 31 orders".
4. Narrow the window to 320 CSS pixels: the printed counts still fit above their bars without overlapping, or the tooltip stays inside the chart.

Record the OS, browser and assistive-technology versions, the build, the date, and the actual result of each step. Repeat whenever the chart component or its library is updated.

## Limits

This is a starting pattern, not a guarantee for every chart. `Chart` is Chart.js, `setActiveElements` its own way to open a tooltip from code, and chartjs-plugin-datalabels a separate package from the Chart.js project; another library's equivalents go in their place. Screen readers differ in how they treat `role="application"`, and some may keep the arrow keys for reading; test the complete dashboard with a keyboard and your target assistive technology.

## What screen readers do (tested by hand by EaseWeb)

`role="application" tabindex="0"`: A part of the page with keys of its own, such as a chart stepped through with the arrow keys. ARIA asks screen readers to pass the keys to the page there, rather than use them to read.

| Screen reader | Browser and system | Arrow keys |
| --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested |
| NVDA | Chrome, Windows 11 | Not tested |
| JAWS | Edge, Windows 11 | Not tested |
| NVDA | Firefox, Windows 11 | Not tested |
| NVDA | Edge, Windows 11 | Not tested |
| JAWS | Firefox, Windows 11 | Not tested |
| VoiceOver | Safari, macOS | Not tested |
| VoiceOver | Safari, iOS | Not tested |
| TalkBack | Chrome, Android | Not tested |

`role="status"`: A polite live region: its changes wait until the screen reader has finished what it is saying.

| Screen reader | Browser and system | Down Arrow | Say all |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

`aria-describedby`: Gives an element a description, such as a hint or an error, from text elsewhere on the page.

| Screen reader | Browser and system | Tab to it | Down Arrow |
| --- | --- | --- | --- |
| JAWS | Chrome, Windows 11 | Not tested | Not tested |
| NVDA | Chrome, Windows 11 | Not tested | Not tested |
| JAWS | Edge, Windows 11 | Not tested | Not tested |
| NVDA | Firefox, Windows 11 | Not tested | Not tested |
| NVDA | Edge, Windows 11 | Not tested | Not tested |
| JAWS | Firefox, Windows 11 | Not tested | Not tested |
| VoiceOver | Safari, macOS | Not tested | Not tested |
| VoiceOver | Safari, iOS | Not tested | Not tested |
| TalkBack | Chrome, Android | Not tested | Not tested |

https://easeweb.dev/learn/screen-reader-support

---

Get this guide in your coding agent: MCP https://easeweb.dev/mcp (setup at https://easeweb.dev/services/developers)
