---
title: "Accessible charts"
description: "The table twin every chart ships with, the generated one-sentence summary, and the markup that stops a chart being an image full of numbers nobody can read."
url: "https://opsinjs.pensievelabs.org/foundations/data-visualisation/accessible-charts"
source: "https://opsinjs.pensievelabs.org/foundations/data-visualisation/accessible-charts.md"
section: "Foundations"
kind: "foundation"
reviewed: "2026-09-20"
reviewer: "engineering"
aliases: ["chart accessibility", "data table twin", "chart alt text", "chart summary"]
---

> Elements written as `<PascalCase … />` below are opsinjs documentation
> components. Their attributes are the content: the values they render are
> generated from `tokens/*.json` and `registry/catalogue.ts` and are
> published separately at https://opsinjs.pensievelabs.org/r/index.json and under the Reference
> section.
> Nothing is missing from this page. The data simply does not live in
> the prose.

<PageTemplate kind="foundation" />

## Overview [#overview]

A chart is an image whose content is numbers. Alt text works for an image whose
content is a picture; it does not work here, because no description of a shape
substitutes for the values, and a reader asking "what was my reading on Tuesday"
cannot be answered by "a line trending upward".

opsinjs therefore requires a **table twin**: the same data, in a real table,
available to everyone, on the same screen. Not hidden behind a hover, not in a
modal, not visually hidden and reachable only by a screen reader. The twin is
available. Sighted readers use it too, because the exact value on Tuesday is a
question everybody has.

<NotBuiltYet what="A chart with a table twin">
  `trend-sparkline` ships without one: its own source records that the
  pointer to a table twin is absent because there is no table twin to point at. No
  chart in the registry provides a table twin today, so this requirement is met by
  no component. Everything below is the specification one will have to satisfy.
</NotBuiltYet>

This is a requirement, not a recommendation. A chart component that does not
provide a table twin does not meet the definition of done on
[Component checklist](../../handbook/contributing/component-checklist.mdx).

## How it works [#how-it-works]

**Three things ship together**, and none of them is optional.

*The chart.* The visual mark. It is exposed to assistive technology as a single
labelled image whose label is the summary sentence, or it is hidden entirely when
the table twin sits immediately beside it. What it must never be is a tree of
unlabelled shapes a screen reader walks through one path element at a time.

*The summary.* One sentence, generated from the data rather than written per
chart, stating what the chart shows, over what window, in which direction, by how
much, and with what caveat. Generated because a hand-written summary is correct
until the data changes and is then silently wrong. The same reasoning produces
every other generated artefact on this site. The pattern is roughly: *metric*,
*window*, *direction and magnitude*, *coverage*, *caveat*. For example:
"Systolic blood pressure, last 14 days: 11 readings, ranging from 118 to 134,
with no clear direction. Three days have no reading."

*The table.* Real table markup with a header row, one row per data point, values
in the same units and precision the chart uses, and missing readings shown as an
empty cell with a stated reason rather than a blank or a zero. It is toggleable
where space is short, and the toggle is never a hover. It is a real control
that is reachable and labelled.

**The order matters.** Summary first, then chart, then table. Somebody who reads
only the first thing gets the answer; somebody who wants the shape gets it next;
somebody who wants Tuesday's number gets it last, which is where they would look
anyway.

**Interactive charts have a keyboard route.** If a point can be hovered to reveal
a value, it can be reached with a keyboard and the value is announced. A chart
whose data is only available on hover has hidden its data from keyboard users,
screen-reader users and everybody on a touchscreen at once.

## Using it [#using-it]

**Build the table first.** It is the data; the chart is a rendering of it. Teams
that build the chart first tend to produce a table twin that is a reconstruction
of the picture rather than the source, and it goes out of step.

<DoDont>
  <DoDont.Do>
    A sparkline with a generated sentence above it and a `Show readings` control
    beneath that expands a table of the fourteen dates and values, with the three
    missing days shown as `No reading`.
  </DoDont.Do>

  <DoDont.Dont>
    The same sparkline with `alt="Blood pressure chart"` and values available
    only on hover. Every value in it is unavailable to anybody not using a mouse,
    and the alt text tells a screen-reader user nothing they could act on.
  </DoDont.Dont>
</DoDont>

**Do not describe the shape in the summary.** "Trending upward" is an
interpretation; "rose from 118 to 134 over 14 days" is the data. Where a
direction genuinely matters, it is separated from its valence. See
[Trends and change](../../health/trends-and-change.mdx). A rise is never
described as good or bad by the chart.

**State the coverage.** How many readings, out of how many expected. A chart
drawn from four of fourteen days is a different claim from one drawn from
fourteen, and the summary is where that is said.

**Do not put the table behind a tab that changes the URL or loses the chart.**
Both should be reachable without navigating away, so the two can be compared.

**Test it by reading the page without looking.** Turn the screen off, or turn on
a screen reader, and answer two questions: what does the chart show, and what was
the value on the third day? If either takes more than a few seconds, the twin is
wrong.

## Tokens [#tokens]

Accessible charts consume the neutral colour roles for the table, the space
scale, and the type scale's value roles for the figures. Those value roles are
what make the table's numbers tabular and aligned. There are no tokens specific
to this page.

<NoDataYet script="scripts/build-tokens.mts" />

## Accessibility impact [#accessibility-impact]

* **This page is the accessibility impact.** The table twin is what makes the
  data in a chart available to screen-reader users, keyboard users, readers who
  find shapes hard to interpret, and anybody who wants an exact number.
* **WCAG 2.2 SC 1.1.1 *Non-text Content* is satisfied by the twin, not by alt
  text.** A description of a data graphic is not an equivalent of it; the
  equivalent is the data.
* **SC 1.4.11 *Non-text Contrast* applies to every mark**, and thin lines are the
  case most likely to pass a spot check and fail in daylight on a phone.
* **SC 2.1.1 *Keyboard* applies to every interactive point.** If hovering reveals
  a value, focusing must reveal the same value.
* **The table is a real table.** Header cells associated with data cells, a
  caption naming the metric and the window, and units in the header rather than
  repeated in every cell. Putting the units in the header is also what makes
  the table readable when somebody copies it out.
* **Missing values are announced as missing.** An empty cell that reads as
  nothing is the [data states](../data-states.mdx) failure again: absence
  mistaken for a reading in the usual range.

The tested screen-reader combinations and the announcement contract are on
[Screen readers](../../accessibility/screen-readers.mdx); the descriptive-writing
guidance is on
[Alt text and descriptions](../../content/alt-text-and-descriptions.mdx).

## Related [#related]

* [Chart anatomy](./chart-anatomy.mdx) covers the axis and window facts the
  summary sentence has to carry.
* [Alt text and descriptions](../../content/alt-text-and-descriptions.mdx) shows
  how to describe dials, ranges and body diagrams non-visually.
* [Data states](../data-states.mdx) shows how a missing reading is rendered, in
  the chart and in the table.
