Accessible CSV and Excel Exports
Permalink to "Accessible CSV and Excel Exports"For many screen reader users, the export button is the most important control in a data grid. A web grid with thousands of rows is slow to browse with a screen reader; the same data in Excel, with its mature table navigation, column-header announcement and filtering, is fast. An export is also how users take data into their own tools — a braille display-friendly CSV, a spreadsheet they have configured for their reader. That only works if the export is clean: proper headers, real values, no layout tricks.
This guide belongs to data export & print.
Spec reference
Permalink to "Spec reference"WCAG applies to the web page, not directly to the downloaded file, but the export control and its result are part of the same task:
- SC 2.4.6 Headings and Labels (AA) and SC 2.4.4 Link Purpose (A) — “Export” is ambiguous. “Export 240 invoices as CSV” names format and scope.
- SC 1.3.1 Info and Relationships (A) — applied by analogy to the file: headers must be identifiable as headers. In XLSX, a defined table (ListObject) with a header row makes Excel and screen readers announce column headers automatically.
- SC 1.4.1 Use of Color (A) — by analogy, status shown only as cell fill colour in the export is lost to many users; export status as text.
- PDF/UA and Office accessibility checkers — Excel’s built-in Accessibility Checker flags missing headers, merged cells, default sheet names and blank rows; aim for zero warnings.
When to use — and when not to
Permalink to "When to use — and when not to"Offer CSV when users need maximum compatibility — any tool, any reader, smallest file. Offer XLSX when headers, types and frozen panes help, which is most of the time for screen reader users working in Excel. Offer both if you can.
Do not use an export as a substitute for an accessible grid. “The table is not accessible, but you can export it” fails the web content itself. Exports complement an accessible grid; they do not excuse an inaccessible one.
The misapplication: generating the export by scraping the rendered HTML — including icons, “Show more” text, visually hidden helper text and the formatted display strings — so that the file has “Overdue (status)” and “$1,240.00 USD” in every cell.
Annotated code example
Permalink to "Annotated code example"<!-- Name says format and scope (SC 2.4.6) -->
<button type="button" id="export-csv">Export 240 invoices as CSV</button>
<button type="button" id="export-xlsx">Export 240 invoices as Excel</button>
<p id="export-status" role="status" class="visually-hidden"></p>
// CSV from the data model, not the DOM: same header names as the grid, raw values
function toCSV(rows, columns) {
const esc = (v) => `"${String(v ?? '').replace(/"/g, '""')}"`;
const header = columns.map(c => esc(c.exportHeader ?? c.header)); // "Amount (EUR)"
const body = rows.map(r => columns.map(c => esc(c.exportValue ? c.exportValue(r) : r[c.key])));
return '' + [header, ...body].map(line => line.join(',')).join('\r\n'); // BOM for Excel UTF-8
}
// XLSX with ExcelJS: named sheet, frozen header, defined table, typed values
import ExcelJS from 'exceljs';
async function toXLSX(rows) {
const wb = new ExcelJS.Workbook();
wb.title = 'Invoices export'; // document title
const ws = wb.addWorksheet('Invoices', { views: [{ state: 'frozen', ySplit: 1 }] });
ws.addTable({
name: 'Invoices', ref: 'A1', headerRow: true, style: { showRowStripes: true },
columns: [{ name: 'Invoice' }, { name: 'Customer' }, { name: 'Status' }, { name: 'Amount (EUR)' }, { name: 'Due date' }],
rows: rows.map(r => [r.id, r.customer, r.status, r.amount, new Date(r.due)]), // numbers and dates as values
});
ws.getColumn(4).numFmt = '#,##0.00';
ws.getColumn(5).numFmt = 'yyyy-mm-dd';
return wb.xlsx.writeBuffer();
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | Expected announcement | AT deviations |
|---|---|---|
| Tab to export button | “Export 240 invoices as Excel, button” | — |
| Activate | “Preparing Excel file…” then “Invoices export downloaded” | Browser download UI announcements vary |
| Open in Excel with JAWS/NVDA | Column headers read when moving across cells | Requires a defined table or JAWS’s “set column titles” |
| Open CSV in a text editor | Header line, then comma-separated rows | Screen readers read commas; some users prefer tab-separated |
Integration context
Permalink to "Integration context"Exports are one route out of the grid; printing is another — see print stylesheets for data tables. Long exports need progress and completion messages, covered in announcing export progress and downloads. Values should follow the same rules as on screen, from formatting numbers and units in table cells: units in headers, not repeated in every cell.
Gotchas
Permalink to "Gotchas"Scope ambiguity. Does “Export” export the visible page, the filtered set, the selection or everything? Say it in the button name, and if there are options, use a menu: “Export selected (12)”, “Export filtered (240)”, “Export all (5,120)”.
Hidden columns. Decide whether hidden columns are exported and say so. Users who hid a column to simplify the screen may still want it in the file — or may not.
CSV delimiters and locales. In locales that use comma as the decimal separator, Excel expects semicolons. Offer the right delimiter for the user’s locale, or prefer XLSX.
Framework notes
Permalink to "Framework notes"AG Grid has exportDataAsCsv and (Enterprise) exportDataAsExcel; use processCellCallback to output raw values instead of formatted display strings, and columnKeys to control which columns are exported. MUI X Data Grid offers CSV export in all tiers and Excel in Premium; provide valueGetter rather than renderCell output. With TanStack Table, build files from table.getFilteredRowModel().rows and each column’s accessor.
// AG Grid: raw values rather than formatted strings
api.exportDataAsCsv({ processCellCallback: ({ value }) => value, fileName: 'invoices.csv' });
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Give every column definition an exportHeader and exportValue alongside its display renderer, so exports are produced from data and never from markup. Ship an ExportMenu component whose items are generated from the grid’s current scope (selection, filtered, all) with counts in their names.
FAQ
Permalink to "FAQ"Is CSV or XLSX more accessible?
For most screen reader users, XLSX with a defined table is more comfortable because Excel announces column headers automatically. CSV is universal and simple; offer both when you can.
Should the export include the filters that were applied?
Yes, either in the file name (“invoices-overdue-2026-10-02.xlsx”) or in a second sheet named “Filters”, so users know what the file represents. Do not put it in title rows above the header.
Does WCAG require exports to be accessible?
WCAG covers the web page. Many organisations’ procurement policies (and the EN 301 549 standard for documents) also cover non-web documents, so treat exports as in scope.
Should totals rows be exported?
Put totals in a separate sheet or as a table total row (which Excel supports), not as a row of data mixed into the table. Mixed totals rows break sorting and filtering.
Related
Permalink to "Related"- Print stylesheets for data tables — the paper route
- Accessible PDF reports — when PDF is required
- Formatting numbers and units — the same rules on screen