Show-as-Table Toggles for Charts
Permalink to "Show-as-Table Toggles for Charts"A show-as-table toggle is a control beside a chart that reveals the chart’s data as an HTML table — either replacing the chart or appearing beneath it. It is the most dependable chart alternative there is: tables are fully supported by every screen reader, sortable, copyable, and readable at any zoom level. It is also useful far beyond screen reader users; analysts often want the numbers, not the picture.
The implementation details decide whether the toggle helps or confuses: what kind of control it is, where the table appears in the DOM, where focus goes, and whether the table and chart can drift apart. This page covers each. It belongs to data visualization & chart alternatives.
Spec reference
Permalink to "Spec reference"Two HTML/ARIA patterns fit:
- Toggle button —
<button aria-pressed="true|false">Show as table</button>when the table replaces the chart. The pressed state says which view is active. - Disclosure —
<button aria-expanded aria-controls>or<details>/<summary>when the table appears alongside the chart.
The table is an ordinary data table with a caption matching the chart’s title (SC 1.3.1). Hidden views use the hidden attribute, so the inactive view leaves the accessibility tree.
Criteria: SC 1.1.1 Non-text Content (the table is the chart’s equivalent), SC 1.3.1, SC 4.1.2 for the toggle’s state, SC 1.3.2 Meaningful Sequence for placement, and SC 3.2.2 On Input — toggling should not move focus unexpectedly.
When to add a toggle — and when to show the table outright
Permalink to "When to add a toggle — and when to show the table outright"Add a toggle when space is tight and the chart is the default view most users want: dashboards, embedded analytics.
Show the table outright when the chart is small and the data is the point — a report page with one chart and a twelve-row table underneath costs nothing and needs no interaction. A toggle is a convenience, not a requirement; the requirement is that the data is available.
The misapplication to name is a “View data” link that opens the table in a new window or downloads a CSV as the only alternative. It works, but it takes users away from the page and its context. Keep the table in place; offer a download as an extra.
Annotated code example
Permalink to "Annotated code example"<section aria-labelledby="orders-title" class="chart-card">
<div class="card-head">
<h3 id="orders-title">Orders per month, 2026</h3>
<!-- SC 4.1.2: toggle state is the active view -->
<button type="button" class="view-toggle" aria-pressed="false" aria-controls="orders-chart orders-table">
Show as table
</button>
</div>
<figure id="orders-chart">
<canvas role="img" aria-label="Orders per month, 2026" aria-describedby="orders-sum"></canvas>
<figcaption id="orders-sum">Orders rose from 820 in January to 1,410 in November.</figcaption>
</figure>
<!-- SC 1.3.2: immediately after the chart in DOM order -->
<div id="orders-table" class="table-scroll" hidden>
<table>
<caption>Orders per month, 2026</caption> <!-- SC 1.3.1: same name as the chart -->
<thead><tr><th scope="col">Month</th><th scope="col">Orders</th></tr></thead>
<tbody><!-- generated from the same array as the chart --></tbody>
</table>
</div>
</section>
const PREF = 'chart-view';
document.querySelectorAll('.view-toggle').forEach((btn) => {
const [chartId, tableId] = btn.getAttribute('aria-controls').split(' ');
const apply = (asTable) => {
btn.setAttribute('aria-pressed', String(asTable));
document.getElementById(chartId).hidden = asTable;
document.getElementById(tableId).hidden = !asTable;
};
apply(safeGet(PREF) === 'table'); // remembered choice, try/catch inside
btn.addEventListener('click', () => {
const asTable = btn.getAttribute('aria-pressed') !== 'true';
apply(asTable); // SC 3.2.2: focus stays on the toggle
safeSet(PREF, asTable ? 'table' : 'chart');
});
});
// One data source for both views
function renderOrders(rows) {
chart.data.labels = rows.map((r) => r.month);
chart.data.datasets[0].data = rows.map((r) => r.orders);
chart.update();
tbody.replaceChildren(...rows.map((r) => tr(r.month, r.orders)));
summary.textContent = summarise(rows);
}
Keeping the label constant (“Show as table”) and letting aria-pressed carry the state is deliberate. A label that flips between “Show as table” and “Show as chart” combined with a pressed state produces contradictory announcements (“Show as chart, pressed”). Pick one: constant label with aria-pressed, or changing label without it.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected behaviour | Announcement |
|---|---|---|
Tab to toggle |
Focus on the button | “Show as table, toggle button, not pressed” |
Enter / Space |
Chart hidden, table shown | “pressed” |
Down Arrow (browse mode) |
Reaches the table next | “Orders per month, 2026, table with 2 columns and 13 rows” |
T (NVDA/JAWS) |
Jumps to the table | Same |
| Revisit page | Table view restored | Toggle reads “pressed” |
Integration context
Permalink to "Integration context"On a dashboard with many charts, a single “Show all charts as tables” preference is kinder than a toggle per card; apply it globally and still offer per-card toggles. The dashboard structure that makes those cards navigable is in landmarks and headings for dashboards.
The table itself follows the normal table rules — caption, headers, formatted numbers — from writing table captions and summaries and formatting numbers and units in table cells.
Gotchas
Permalink to "Gotchas"Hiding the chart with visibility: hidden. It keeps the chart’s space and, in some browsers, its accessibility node. Use hidden.
Large data sets. A chart of 10,000 points should not produce a 10,000-row table in place. Aggregate to the chart’s resolution (daily, monthly) and offer a download for the raw data.
Storage failures. localStorage can throw in private windows; wrap reads and writes, and default to the chart when unavailable.
Design system notes
Permalink to "Design system notes"Make the toggle part of the chart card component, fed by the same data model as the chart, with a defaultView prop and a global user preference. The component renders the table with the chart’s title as caption, formats numbers with the chart’s formatters, and never lets product teams supply a separate table that could drift.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"Should a chart have a "view as table" option?
Every chart should make its data available as a table. A toggle is a good way to do that when space is limited; on report pages, showing the table under the chart may be simpler.
Should the toggle be a button with aria-pressed or aria-expanded?
aria-pressed when the table replaces the chart, because the state is which view is active. aria-expanded, or a native details element, when the table appears in addition to the chart.
Where should focus go after toggling to the table?
It should stay on the toggle. The table appears immediately after the chart in reading order, so users reach it with their next navigation step.
Should the table offer a download as well?
It is a useful extra — many users want the data in a spreadsheet — but it should supplement the in-page table, not replace it, so the data stays available in context.
How do I keep the chart and table consistent?
Render both, and the text summary, from the same data array in the same function, so any update changes all three together.
Related
Permalink to "Related"- Accessible Chart.js configuration — a chart that needs this toggle
- Table captions & summaries — naming the table
- Landmarks and headings for dashboards — toggles across a dashboard