Offering a Downloadable Data Table Fallback

A user who cannot read your chart — or simply wants the raw numbers in their own tools — needs the underlying data as structured text, both on the page and as a file they can save.

The fix is two outputs from one data source: an inline <table> a screen reader can navigate cell by cell, and a CSV download that hands the same rows to a spreadsheet, a Braille display, or a script. This page is part of alternative representations for charts, and it turns the shared series model into the most reliable non-visual channel there is. Where the text summary gives the gist, the table and its export give every exact value.

Diagnostic checklist

One series, an inline table and a CSV download A shared series splits into an on-page semantic table for screen readers and a downloadable CSV file for offline tools. Series one source of truth Inline <table> Month Revenue Jan 3.1M Feb 4.0M Mar 4.2M screen-reader navigable CSV download Month,Revenue Jan,3100000 Feb,4000000 Mar,4200000 opens in the user's own tools
The inline table and the CSV are two serialisations of one series, so they always agree with each other and with the chart.
Colour plus redundant shape encoding Four categories encoded by hue and a repeated shape, each clearing 3:1 contrast. Categorical encoding — colour + redundant cueSeries A4.9:1Series B4.8:1Series C5.2:1Series D5.8:1Shape repeats the category so colour is never the only signaland each hue clears 3:1 against the background
The encoding principle for a downloadable data table fallback: pair every hue with a shape so the category reads without colour, and keep each swatch above 3:1.

Broken vs fixed

// ❌ BROKEN: a div grid with no semantics and naive CSV that corrupts on commas.
function renderFallback(root: HTMLElement, series: Series): void {
  const grid = document.createElement("div");
  for (const p of series.points) {
    // A11Y: divs carry no row/column relationships — a screen reader hears
    // a flat wall of numbers with no headers to associate them with.
    grid.insertAdjacentHTML("beforeend", `<div>${p.xLabel} ${p.y}</div>`);
  }
  root.appendChild(grid);
  const csv = series.points.map((p) => `${p.xLabel},${p.y}`).join("\n");
  // A field like "Q1, adjusted" silently splits into two columns — data corruption.
  root.querySelector("a")!.setAttribute("href", `data:text/csv,${csv}`);
}
// ✅ FIXED: semantic table + safely escaped CSV, both from one series.
function renderFallback(root: HTMLElement, series: Series): HTMLTableElement {
  const table = document.createElement("table");
  const caption = document.createElement("caption");
  caption.textContent = `${series.name} by period (${series.unit})`;
  table.appendChild(caption);

  const thead = table.createTHead();
  const hr = thead.insertRow();
  // A11Y: scope="col" ties every data cell to its column header (WCAG 1.3.1).
  for (const h of ["Period", series.name]) {
    const th = document.createElement("th");
    th.scope = "col";
    th.textContent = h;
    hr.appendChild(th);
  }
  const tbody = table.createTBody();
  for (const p of series.points) {
    const row = tbody.insertRow();
    const rowHead = document.createElement("th");
    rowHead.scope = "row";               // the period labels each row
    rowHead.textContent = p.xLabel;
    row.appendChild(rowHead);
    row.insertCell().textContent = p.yFormatted; // show the formatted value
  }
  root.appendChild(table);
  return table;
}

// Robust CSV: quote fields with commas/quotes/newlines; double embedded quotes.
function toCsv(series: Series): string {
  const esc = (v: string): string =>
    /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
  const header = ["Period", series.name].map(esc).join(",");
  const rows = series.points.map((p) => [esc(p.xLabel), esc(String(p.y))].join(","));
  return [header, ...rows].join("\r\n"); // CRLF is the CSV convention
}

The two failures the fixed version closes are structural and textual. Structurally, real <th scope> headers put the row/column relationships into the accessibility tree, so a screen reader announces “Period, March, Revenue, 4.2M” instead of reading naked numbers with no context. Textually, quoting any field that contains a comma, quote, or newline is what keeps a label like "Q1, adjusted" in one column instead of splitting the whole file. Both outputs read the same series, so the download can never disagree with the table on the page.

Step-by-step fix

// Download the series as a CSV file without leaking the object URL.
function downloadSeriesCsv(series: Series): void {
  const bom = "";                    // A11Y/i18n: helps spreadsheets read UTF-8
  const blob = new Blob([bom + toCsv(series)], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `${series.name.replace(/\s+/g, "-").toLowerCase()}.csv`;
  document.body.appendChild(a);
  a.click();
  a.remove();
  URL.revokeObjectURL(url);                // PERF: release the blob immediately
}

Verification

// Assert CSV escaping and row count are correct.
const messy: Series = {
  name: "Notes", unit: "count",
  points: [{ x: 0, xLabel: 'Q1, adjusted', y: 5, yFormatted: "5" }],
};
const csv = toCsv(messy);
console.assert(csv.includes('"Q1, adjusted"'), "fields with commas must be quoted");
console.assert(csv.split("\r\n").length === 2, "one header + one data row expected");

// Assert the inline table exposes headers to AT.
const table = renderFallback(document.body, messy);
console.assert(table.querySelectorAll("th[scope]").length >= 2, "table needs scoped headers");

In DevTools, open the Accessibility tree over the table and confirm it is exposed as a table with column and row headers, not a generic group. Then download the CSV, open it in a spreadsheet, and confirm a field containing a comma stays in a single cell and any non-ASCII characters render correctly — the BOM and quoting are what make that pass.

Why a data table is the most robust alternative channel

Of all the non-visual representations, the accessible table is the one to ship first, because it needs no special hardware, degrades gracefully, and answers the question the other channels dodge: “what is the exact value here?”. A table works with server rendering, so it is present before JavaScript runs; it works with a Braille display, which lays out rows and columns spatially; and it exports trivially to CSV, handing the user their own analysis tools. The summary and the audio channel are about orientation — direction, shape, extremes — while the table is about access to the record. This is also why the table should be built from the same series that everything else reads: the moment the download or the inline table is generated from a second copy of the data, they can silently disagree with the chart, and a user comparing the CSV against the picture loses trust in both. The screen-reader semantics of that table — headers, captions, summaries, and how assistive technology navigates it — are covered in depth in exposing chart data as an accessible data table.

Edge cases & gotchas

  • Leading =, +, -, @ in fields. Spreadsheets interpret these as formulas (CSV injection). If a label can start with one, prefix the field with a quote or an apostrophe on export so it is treated as text.
  • Missing UTF-8 BOM. Without , some spreadsheets open the file as the local codepage and mojibake any accented or non-Latin labels. The BOM is a one-character fix.
  • Huge series blocking the main thread. Stringifying hundreds of thousands of rows synchronously freezes the tab. Stream the serialisation or generate the file server-side above tens of thousands of rows.
  • Forgetting to revoke the object URL. Every createObjectURL without a matching revokeObjectURL pins the blob in memory for the page’s life. Revoke immediately after the click.
  • Table and chart drifting after a filter. An interactive filter changes the visible series; regenerate the table body and the CSV from the filtered series, or the download reports rows the chart no longer shows.
  • Empty state. With no data, render a table with a caption stating it is empty rather than an empty <table> element, and disable the download button so users are not handed a header-only file with no explanation.
Bars encoded by texture, not only colour Four bars distinguished by pattern fills as well as hue. valueNorthSouthEastWesttexture repeats the series identity beyond colour
Redundant texture on each bar for a downloadable data table fallback — the pattern carries series identity even where colour perception fails.

Frequently Asked Questions

Should the data table always be visible, or hidden until requested?

Either works, provided it is reachable. A visible table below the chart helps everyone and needs no extra affordance. If space is tight, put it behind a native <details> disclosure or a labelled toggle so it is announced and keyboard operable — just do not hide it with display: none and no way back, which removes it from the accessibility tree entirely. The download button should always be visible and labelled.

Do I need a CSV download if there is already an inline HTML table?

They serve different needs, so offer both where you can. The inline table gives immediate on-page access for screen-reader and keyboard users; the CSV hands the raw numbers to spreadsheets, Braille displays, and scripts for offline analysis in the user’s own tools. The CSV is cheap once the table exists, since both serialise the same series, so the marginal cost of adding it is small.

How do I stop CSV values from being misread as spreadsheet formulas?

Guard against CSV injection: any field beginning with =, +, -, or @ can be executed as a formula when opened. Prefix such fields with a leading apostrophe or wrap them so the spreadsheet treats them as text, and always quote fields containing commas, quotes, or newlines. This keeps labels like -3.1% from being evaluated and keeps messy text fields in a single column.