# Charts whose numbers a screen reader can't read

Source: https://easeweb.dev/learn/chart-alternatives
Topics: Tables and data > Charts have a text alternative (WCAG 1.1.1)
The fix: Give the chart's canvas role="img" and a name built from its data. Either write every value into that name, or give it a short name with the main finding and put the values in a table beside the chart, built from the same data. Update both whenever the chart changes.
Test with: screen reader, zoom
References: https://www.w3.org/WAI/WCAG22/Understanding/non-text-content.html https://www.w3.org/WAI/tutorials/images/complex/ https://www.w3.org/WAI/WCAG22/Techniques/failures/F20 https://www.w3.org/WAI/WCAG22/Techniques/failures/F67 https://www.chartjs.org/docs/latest/general/accessibility.html https://www.highcharts.com/docs/accessibility/accessibility-module 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 opens with a card headed "Orders per day". Under the heading, a line says "Last week", and a bar chart shows one bar for each day, Monday to Sunday. The seller can see at a glance that Saturday was the busiest day, with 31 orders, and that Monday was the quietest, with 12.

A screen reader reading down the card says "Orders per day, heading" and "Last week", and then moves on to whatever comes after the card. The chart isn't mentioned at all. In a second common build, the chart has been given a name, and the screen reader now says "Orders chart, image". The listener knows a chart is there, but not what it shows: not the busiest day, and not one of the seven counts.

## Why it fails

The chart is drawn by Chart.js on a `canvas` element, as in the library's getting-started example, `<canvas id="myChart"></canvas>`. A canvas is a surface for pixels. The bars, the day labels and the numbers on the axis are painted onto it, and none of them exists as text the browser can pass to a screen reader. Chart.js's own documentation says so: the canvas content will not be accessible to screen readers, and the text alternative has to be added by the page, with ARIA on the canvas or with fallback content inside it. The empty canvas has neither, so there is nothing to read.

1.1.1 Non-text Content asks that every piece of non-text content that tells something has a text alternative that serves the same purpose. A chart is there to show its data and what the data says, so its text alternative has to say that. The second build adds `role="img"` and `aria-label="Orders chart"`. The chart now has a name, and that is all it has: the name says what the thing is, not what it tells. Highcharts does much the same without its accessibility module, naming the chart's SVG with its title alone. A name like this fails 1.1.1 just as surely as no name does, because nothing in it serves the purpose the chart serves.

## Who is affected

A dashboard is where a seller checks how the week went, and the chart is often the only place on the page where those counts appear.

## What automation and AI miss

axe has no rule for a canvas with no text, so the first build passes axe. Its `role-img-alt` rule reports an element with `role="img"` and no name, which catches react-chartjs-2, since its canvas sets `role="img"` and gives no name unless one is passed. Adding `aria-label="Orders chart"` silences that rule, and so does any name at all, however little it says. A quick fix written by hand or by an AI assistant can do just that. This audit recognises charts drawn by Chart.js, Highcharts, Recharts, ApexCharts, ECharts and Plotly. It reports one as a finding when the chart has no text alternative at all, or a name that is only a word such as "chart", and no table beside it. Every other chart it finds becomes a lead, quoting the name or caption it has, for a person to check against the bars. Only someone who can see the chart can tell whether a name says what the bars show.

## Before: a chart drawn on a canvas with nothing to read

```html
<section class="card">
  <h2>Orders per day</h2>
  <p class="range">Last week</p>
  <canvas id="orders"></canvas>
</section>
```

```js
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const orders = [12, 15, 14, 18, 24, 31, 20];
const canvas = document.getElementById('orders');

new Chart(canvas, {
  type: 'bar',
  data: {
    labels: days.map(day => day.slice(0, 3)),
    datasets: [{ label: 'Orders', data: orders }]
  }
});
```

- The bars, the day labels and the axis are painted as pixels, so none of them can be read.
- The canvas has no name and nothing inside it, so a screen reader passes it without a word.

## Before: a chart named only by what it is

```html
<section class="card">
  <h2>Orders per day</h2>
  <p class="range">Last week</p>
  <canvas id="orders" role="img" aria-label="Orders chart"></canvas>
</section>
```

```js
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const orders = [12, 15, 14, 18, 24, 31, 20];
const canvas = document.getElementById('orders');

new Chart(canvas, {
  type: 'bar',
  data: {
    labels: days.map(day => day.slice(0, 3)),
    datasets: [{ label: 'Orders', data: orders }]
  }
});
```

The canvas is now read as an image with a name, which is right. What the name says is where it fails:

- "Orders chart" says what the thing is, and nothing about what it shows.
- No day and no count is anywhere in text, so the busiest day is still lost.

## After: write the data into the chart's name

```html
<section class="card">
  <h2>Orders per day</h2>
  <p class="range">Last week</p>
  <canvas id="orders" role="img"></canvas>
</section>
```

```js
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const orders = [12, 15, 14, 18, 24, 31, 20];
const canvas = document.getElementById('orders');

new Chart(canvas, {
  type: 'bar',
  data: {
    labels: days.map(day => day.slice(0, 3)),
    datasets: [{ label: 'Orders', data: orders }]
  }
});
canvas.setAttribute('aria-label', describe());

function describe() {
  const top = orders.indexOf(Math.max(...orders));
  const each = days.map((day, i) => `${day} ${orders[i]}`).join(', ');
  return `Orders per day, last week. Busiest on ${days[top]}, with ${orders[top]}. ${each}.`;
}
```

1. Give the canvas `role="img"`, so it is read as one image with a name. A canvas has no role of its own that takes a name.
2. Build the name from the same two arrays the chart is drawn from: what the chart shows, its main finding, then every day with its count. A screen reader now says "Orders per day, last week. Busiest on Saturday, with 31. Monday 12, Tuesday 15, …, Sunday 20, image".
3. Set the name in the code that draws the chart, so a new week's data gives a new name.

This fixes both Befores and passes 1.1.1 for a chart this small: seven values fit in a sentence a listener can follow. The numbers reach only screen reader users, though, and as one string, which can't be paused on Thursday or read again from Friday. A chart with dozens of points, or several series, needs the table in the next fix.

## After: a short name, and the data in a table beside the chart

```html
<section class="card">
  <h2>Orders per day</h2>
  <p class="range">Last week</p>
  <canvas id="orders" role="img"></canvas>
  <details>
    <summary>Show the numbers</summary>
    <table id="orders-table">
      <caption>Orders per day, last week</caption>
      <thead>
        <tr>
          <th scope="col">Day</th>
          <th scope="col">Orders</th>
        </tr>
      </thead>
      <tbody></tbody>
    </table>
  </details>
</section>
```

```js
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const orders = [12, 15, 14, 18, 24, 31, 20];
const canvas = document.getElementById('orders');

new Chart(canvas, {
  type: 'bar',
  data: {
    labels: days.map(day => day.slice(0, 3)),
    datasets: [{ label: 'Orders', data: orders }]
  }
});
canvas.setAttribute('aria-label', describe());
fillTable();

function describe() {
  const top = orders.indexOf(Math.max(...orders));
  return `Orders per day, last week. Busiest on ${days[top]}, with ${orders[top]}. Every day's count is under Show the numbers.`;
}

function fillTable() {
  const body = document.querySelector('#orders-table tbody');
  body.replaceChildren();
  days.forEach((day, i) => {
    const row = body.insertRow();
    const head = document.createElement('th');
    head.scope = 'row';
    head.textContent = day;
    row.append(head);
    row.insertCell().textContent = orders[i];
  });
}
```

1. Give the canvas `role="img"` and a short name built from the data: what the chart shows, its main finding, and where the numbers are. A screen reader says "Orders per day, last week. Busiest on Saturday, with 31. Every day's count is under Show the numbers, image".
2. Put a `details` element right under the chart, with a `summary` that says what it holds, "Show the numbers". It stays closed until someone opens it, so the card keeps its look.
3. Inside it, write a `table` with a caption and a header for each column and each row, filled from the same two arrays. A screen reader moves through it cell by cell, reading "Saturday, 31" with its headers, and anyone can open it to read an exact count.
4. Run both in the code that draws the chart, so the name and the rows change with the bars.

This fixes both Befores and passes 1.1.1 on a chart of any size. The table sits outside the canvas: what is inside an element with `role="img"` is presentational, and browsers are told not to pass it to a screen reader, so a table placed in the canvas as fallback content would not be read.

## Which option to use

Both fixes pass 1.1.1 on this chart. Writing the data into the name is the smallest change, one attribute and a function, and it suits a small chart in a tight space, such as a card with a handful of bars. Its numbers reach only screen reader users, read as one string, and the string grows with every bar. The table costs a few more lines and a closed disclosure under the chart, and in return it works for a chart of any size, lets a listener step through the days and come back to one, and gives exact counts to sighted people who'd rather read than estimate. That makes it the fix to start with, since the name-only fix stops working as soon as the chart grows.

## Implementation decisions

**What the name says.** Write what the chart is there to tell: what it measures, over what period, and the one thing a reader should take from it. "Busiest on Saturday, with 31" is what the seller looks at the chart for. Leave out how it is drawn ("bar chart with blue bars"), and don't start with "chart of": the screen reader already says "image".

**Charts whose data changes.** A dashboard redraws its chart when the range changes, a filter is applied or new orders come in. Set the name and the table rows again in the same function that calls `chart.update()`, so the text never describes last week's bars. A name written once in the HTML, beside a chart redrawn by a script, is W3C's failure F20.

**`aria-describedby` and `aria-details`.** Pointing `aria-describedby` at the table reads it as one flat run of words, with its rows and headers lost, so it suits a sentence, not a table. `aria-details` points at the table without flattening it, but screen readers differ in whether they say that details exist and how a person gets to them. Neither is needed here: the table sits right after the chart, and the name says where it is.

**A table that is always shown.** On a report page, or anywhere the exact numbers matter, show the table without a `details` around it, or offer a switch between the chart and the table. Either way, it is the same table, built from the same data.

**Chart libraries' own features.** Highcharts' accessibility module, loaded as `accessibility.js`, adds descriptions and keyboard access to the chart, and its export-data module can show the chart's data as a table. Recharts' `accessibilityLayer`, on by default since version 3.0, adds keyboard access to the chart. Use them where they help, and check what a screen reader actually hears: a library's description is built from its settings, and it still needs the title and finding you give it.

**When the chart only repeats the text.** If the numbers are already on the page as text, such as a table the chart sits beside, the chart adds nothing for a screen reader. It still needs a short name that says what it shows and that the numbers are in the table, or it can be hidden with `aria-hidden="true"`. A chart's SVG left with no `role` and no name is handled differently from one screen reader to another, skipped by some and announced as a group by others, and the text drawn in it, such as the axis labels, can be read out as stray words.

Series told apart only by colour, such as two lines in blue and orange with a legend, are a separate failure of 1.4.1 Use of Color, which [order statuses shown only by colour](/learn/status-colours) covers on a status marker. A chart exported as a picture, such as a PNG from a spreadsheet, needs the same text alternative. Its labels are not images of text under 1.4.5, which leaves out text in a picture with significant other visual content, such as a graph, but a table of figures drawn into an image is, as [size charts and notices drawn into images](/learn/images-of-text) explains.

## Verify the fix

1. In the code, find every chart: each `canvas` and `svg` a chart library draws, and every image of a chart.
2. Read each chart's name in the browser's accessibility tree. It says what the chart shows and its main finding, not only "chart" or the chart's title.
3. Compare the name, and the table if there is one, with the chart's data: every value the chart draws is there, and the finding matches the largest or most telling one.
4. With a screen reader, read the card from the top. The chart is announced with its finding, and each day's count can be reached and read on its own, in the name or in the table.
5. Change the data: pick another range, apply a filter, or load the page on another day. Read the chart again, and check that the name and the table give the new figures.

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 data source changes.

## Limits

This is a starting pattern, not a guarantee for every chart. `Chart` is Chart.js, and the same text alternative applies to a chart drawn by any library or by hand. This guide covers a chart's text alternative. A chart whose points, legend or tooltips do something only for a pointer, such as a value shown only on hover or a bar that filters the page when clicked, is a different failure: a legend is covered in [chart legends a keyboard can't toggle](/learn/chart-legends), and a value shown only on hover in [chart tooltips a keyboard can't open](/learn/chart-tooltips). For an image that isn't a chart, see [images that tell something but have no alt text](/learn/informative-images). Test the complete dashboard with your target assistive technology.

---

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