Writing Custom axe-core Rules for Complex Data Tables
A quarterly report table with a two-row header, a spanning group label over each quarter and a row header per region passes every built-in accessibility rule in the catalogue. Read aloud by a screen reader, the same table announces “Growth, 3 percent” with no indication of which quarter that growth belongs to, because the group header was never associated with the cells under it. This guide is part of Component-Specific Rule Writing, and it builds the check that catches that class of defect: a coordinate-grid walk that proves every data cell resolves to at least one row header and at least one column header, reporting the exact cells that do not.
Root Cause
The built-in table rules each assert a local property, and every one of them is satisfied by the broken table. th-has-data-cells checks that a header has cells beneath or beside it — the group header does, so it passes. td-headers-attr checks that ids listed in a headers attribute refer to cells in the same table — there is no headers attribute, so the rule is inapplicable. scope-attr-valid checks that a scope value is one of the four permitted keywords — col is valid, so it passes. empty-table-header checks that a header cell is not blank. Not one of them asks the question the screen-reader user is asking, which is global rather than local: for this cell, in this position, is there a header in both axes?
The failure is a direct consequence of how scope works. scope="col" associates a header with the cells in its own column — the column it occupies in the table’s coordinate grid. A <th colspan="2" scope="col">Q1</th> in the first header row occupies two grid columns, so it does associate with both. But add the second header row with Revenue and Growth under it, each also scope="col", and the cells below now have two candidate column headers per column. Some assistive technology reads only the nearest one. More importantly, the moment the group header is placed in a <th> that does not sit directly above the column in the grid — a common outcome when a stub cell is omitted or a rowspan shifts the row-header column — the association breaks entirely and the cells resolve to Revenue alone. The HTML header algorithm has no notion of “this header applies to a group of columns two levels down”; expressing that requires giving each header an id and listing the ids the cell depends on in its headers attribute.
Spans create the second failure mode, and it is quieter. A rowspan="3" row header in the first column covers three rows, so the second and third rows contain no header cell of their own. If a later colspan="2" data cell in those rows shifts the column indices, a cell can end up in a grid position with no th to its left in any row it occupies and no th above it in any column it occupies — a data cell with no header in either axis, rendered in a table where every other cell is fine. This is precisely a WCAG 2.2 SC 1.3.1 (Info and Relationships) failure: the relationship is conveyed visually by position under a spanning label, and it is not programmatically determinable. Nothing short of walking the grid the way the browser does will find it, which is why this check builds its own coordinate map before it asserts anything.
Configuration
The check has a single dependency on the run configuration: the table must be inside the scan context and not swallowed by an exclude selector, which is a question of axe-core configuration and setup rather than of rule authoring. Given that, the check builds the coordinate grid first, exactly as the HTML table model does — placing each cell at the next free column, expanding it across its colspan, and carrying it down through its rowspan. With the grid in hand, resolving a cell’s headers becomes two straight-line scans, and reporting an orphan becomes a coordinate rather than a vague pointer at the table.
// a11y/rules/checks/table-cell-headers-resolve.js
// Place every cell into grid[row][col], honouring colspan and rowspan the way
// the HTML table model does. One cell object occupies every slot it spans.
function buildGrid(table) {
const grid = [];
// Rows of a nested table belong to that table, not this one.
const rows = Array.from(table.rows).filter((row) => row.closest('table') === table);
rows.forEach((row, rowIndex) => {
grid[rowIndex] = grid[rowIndex] || [];
let col = 0;
for (const cell of row.cells) {
while (grid[rowIndex][col] !== undefined) col += 1; // slot taken by a rowspan
const colspan = Math.max(1, cell.colSpan);
// rowspan="0" means "through to the end of the row group".
const rowspan = cell.rowSpan === 0
? rows.length - rowIndex
: Math.max(1, cell.rowSpan);
for (let r = rowIndex; r < rowIndex + rowspan; r += 1) {
grid[r] = grid[r] || [];
for (let c = col; c < col + colspan; c += 1) grid[r][c] = cell;
}
col += colspan;
}
});
return grid;
}
function slotsOf(grid, cell) {
const slots = [];
grid.forEach((row, r) => row.forEach((occupant, c) => {
if (occupant === cell) slots.push({ r, c });
}));
return slots;
}
// No headers attribute: walk left along every row the cell occupies and up
// every column it occupies, honouring the scope each header declares.
function implicitHeaders(grid, cell) {
const rowHeaders = new Set();
const colHeaders = new Set();
for (const { r, c } of slotsOf(grid, cell)) {
for (let x = c - 1; x >= 0; x -= 1) {
const th = grid[r][x];
if (!th || th === cell || th.tagName !== 'TH') continue;
const scope = (th.getAttribute('scope') || '').toLowerCase();
if (scope === 'col' || scope === 'colgroup') continue; // claims a column only
rowHeaders.add(th);
}
for (let y = r - 1; y >= 0; y -= 1) {
const th = grid[y][c];
if (!th || th === cell || th.tagName !== 'TH') continue;
const scope = (th.getAttribute('scope') || '').toLowerCase();
if (scope === 'row' || scope === 'rowgroup') continue; // claims a row only
colHeaders.add(th);
}
}
return { rowHeaders, colHeaders, dangling: [] };
}
// A headers attribute overrides implicit resolution entirely, so every id in
// it must exist, must be a th, and must live in this table.
function explicitHeaders(table, grid, cell) {
const rowHeaders = new Set();
const colHeaders = new Set();
const dangling = [];
const slots = slotsOf(grid, cell);
const minRow = Math.min(...slots.map((s) => s.r));
const minCol = Math.min(...slots.map((s) => s.c));
const ids = (cell.getAttribute('headers') || '').trim().split(/\s+/).filter(Boolean);
for (const id of ids) {
const target = table.querySelector(`[id="${CSS.escape(id)}"]`);
if (!target || target.tagName !== 'TH') { dangling.push(id); continue; }
const at = slotsOf(grid, target);
if (at.length === 0) { dangling.push(id); continue; }
// Classify geometrically: a header entirely above the cell is a column
// header, one entirely to its left is a row header.
if (Math.max(...at.map((p) => p.r)) < minRow) colHeaders.add(target);
else if (Math.max(...at.map((p) => p.c)) < minCol) rowHeaders.add(target);
else colHeaders.add(target);
}
return { rowHeaders, colHeaders, dangling };
}
export const tableCellHeadersResolve = {
id: 'table-cell-headers-resolve',
metadata: {
impact: 'serious',
messages: {
pass: 'Every data cell resolves to a row header and a column header',
fail: '${data.orphanCount} cell(s) resolve to no header — ${data.orphans}. '
+ 'Give each header an id and list them in the cell headers attribute.',
incomplete: 'Table is ${data.reason}, so the grid is not final yet',
},
},
evaluate: function (node) {
if (node.getAttribute('aria-busy') === 'true') {
this.data({ reason: 'still loading (aria-busy="true")' });
return undefined;
}
const grid = buildGrid(node);
const seen = new Set();
const orphans = [];
grid.forEach((row, r) => row.forEach((cell, c) => {
if (!cell || seen.has(cell) || cell.tagName !== 'TD') return;
seen.add(cell);
const resolved = cell.hasAttribute('headers')
? explicitHeaders(node, grid, cell)
: implicitHeaders(grid, cell);
const missing = [];
if (resolved.rowHeaders.size === 0) missing.push('no row header');
if (resolved.colHeaders.size === 0) missing.push('no column header');
if (resolved.dangling.length > 0) {
missing.push(`unknown ids: ${resolved.dangling.join(' ')}`);
}
if (missing.length > 0) orphans.push({ cell, r, c, missing });
}));
this.data({
orphanCount: orphans.length,
// Three coordinates are enough to locate the pattern; the related nodes
// carry the rest.
orphans: orphans.slice(0, 3)
.map((o) => `R${o.r + 1}C${o.c + 1} (${o.missing.join(' + ')})`)
.join(', '),
});
this.relatedNodes(orphans.map((o) => o.cell));
return orphans.length === 0;
},
};
export const tableHasAccessibleName = {
id: 'table-has-accessible-name',
metadata: {
impact: 'moderate',
messages: {
pass: 'The table is named by a caption or an ARIA label',
fail: 'Add a <caption> element, or aria-labelledby pointing at the heading above the table',
},
},
evaluate: function (node) {
// :scope keeps a nested table's caption from counting for this one.
const caption = node.querySelector(':scope > caption');
if (caption && caption.textContent.trim()) {
this.relatedNodes([caption]);
return true;
}
return axe.commons.text.accessibleText(node).trim().length > 0;
},
};
export const complexTableRule = {
id: 'complex-table-headers',
selector: 'table',
matches: function (node) {
const role = (node.getAttribute('role') || '').toLowerCase();
if (role === 'presentation' || role === 'none') return false; // layout table
if (node.rows.length < 2) return false;
// Only genuinely complex tables: simple ones are covered by the built-ins.
const headerRows = node.querySelectorAll(':scope > thead > tr').length;
const spans = node.querySelectorAll('th[colspan], th[rowspan]').length;
return headerRows > 1 || spans > 0;
},
tags: ['wcag2a', 'wcag131', 'cat.tables', 'custom'],
metadata: {
description: 'Data cells in a complex table must resolve to a row and a column header',
help: 'Give every header an id and list the ids each cell depends on in its headers attribute',
},
all: ['table-cell-headers-resolve', 'table-has-accessible-name'],
};
Validation
Assert both polarities against the same fixture shape: the version with scope alone must fail with the coordinate of the orphaned cell, and the version wired with headers and id must pass. Checking the message text rather than just the violation count is what proves the coordinate reporting still works after a refactor.
// tests/a11y/complex-table.browser.test.ts (Vitest browser mode, Chromium)
import { describe, it, expect, beforeAll } from 'vitest';
import axe from 'axe-core';
import {
tableCellHeadersResolve, tableHasAccessibleName, complexTableRule,
} from '../../a11y/rules/checks/table-cell-headers-resolve.js';
beforeAll(() => {
axe.configure({
checks: [tableCellHeadersResolve, tableHasAccessibleName],
rules: [complexTableRule],
});
});
async function run(html: string) {
document.body.innerHTML = html;
return axe.run(document.body, {
runOnly: { type: 'rule', values: ['complex-table-headers'] },
});
}
const SCOPE_ONLY = `<table><caption>Revenue by region</caption><thead>
<tr><th scope="col">Region</th><th colspan="2" scope="col">Q1</th></tr>
<tr><th scope="col">Region</th><th scope="col">Revenue</th><th scope="col">Growth</th></tr>
</thead><tbody>
<tr><th scope="row">North</th><td>4.2</td><td>3%</td></tr>
</tbody></table>`;
const WIRED = `<table><caption>Revenue by region</caption><thead>
<tr><th id="h-region" scope="col">Region</th><th id="h-q1" colspan="2">Q1</th></tr>
<tr><th id="h-region2">Region</th><th id="h-rev">Revenue</th><th id="h-grw">Growth</th></tr>
</thead><tbody>
<tr><th id="h-north" scope="row">North</th>
<td headers="h-north h-q1 h-rev">4.2</td>
<td headers="h-north h-q1 h-grw">3%</td></tr>
</tbody></table>`;
describe('complex-table-headers', () => {
it('reports the coordinate of a cell with no group-level column header', async () => {
const r = await run(SCOPE_ONLY);
expect(r.violations).toHaveLength(1);
const message = r.violations[0].nodes[0].all[0].message;
expect(message).toContain('R3C3');
expect(message).toContain('no column header');
});
it('passes once every cell lists the headers it depends on', async () => {
const r = await run(WIRED);
expect(r.violations).toEqual([]);
});
it('is inapplicable to a layout table', async () => {
const r = await run('<table role="presentation"><tr><td>a</td><td>b</td></tr></table>');
expect(r.inapplicable.map((i) => i.id)).toContain('complex-table-headers');
});
});
Run against a real report page, the difference in yield is the argument for the rule. On a fourteen-column quarterly table with 220 body rows, the built-in table rules reported six violations, all of them headers attributes pointing at elements that were not header cells. The grid walk reported those six plus 84 cells with no group-level column header and 22 cells in rowspan shadows with no row header at all.
Edge Cases and Conditional Guards
rowspan="0"and omitted stub cells. Arowspanof zero spans to the end of the row group, and the grid builder must expand it rather than treating it as one row, or every cell below it shifts one column left and the whole report becomes noise. Omitted stub cells in the corner of a two-level header have the opposite effect, shifting header columns right; both are why the check builds the grid instead of readingcellIndex.- Nested and layout tables. A
<table>inside a<td>produces rows that appear in the outer table’srowscollection, so the builder filters onrow.closest('table') === tableand the rule’smatchesexcludesrole="presentation"androle="none". Without both guards, a layout table used for an email template generates a violation for every cell in it, which is the fastest possible way to get the rule disabled. - ARIA grids and virtualised rows. A
role="grid"built fromdivelements has norowscollection at all, so this check does not apply to it; write a sibling check that readsaria-rowindexandaria-colindexinstead. When only a window of rows is in the DOM, the result is a sample rather than a proof, and the honest approach is to scan the row component in isolation as well — the timing side of that is covered in DOM inspection for dynamic content.
Pipeline Impact
Report the orphan count, not the violation count. One badly wired table produces a single violation with 106 nodes attached, and a gate that compares violation counts across commits will read a fix that halves the orphans as no change at all. Sum nodes.length per rule id, store that number as the budget, and let the ratchet work on it.
Set the header check to serious so it blocks, and keep the naming check at moderate so a missing <caption> reports as a warning — a table without a caption is navigable, a table without headers is not. Because a single page can contribute a hundred nodes, cap what goes into pull-request annotations at the first ten per table and put the rest in the uploaded JSON artifact; the pattern in a report table repeats, so ten coordinates and a total are enough for a developer to find the loop that generates the markup. When the same table component appears in a design system, move the rule into the component’s own test run rather than scanning every page that embeds it, and version it with the component as described in custom rule testing and distribution.
Common Pitfalls
- Reading
cell.cellIndexas the column position, which ignores everycolspanandrowspanabove the cell and silently misaligns the whole grid. - Treating a
headersattribute as valid because the ids exist somewhere in the document; they must resolve to header cells inside the same table, and an id pointing at a<td>is a defect the built-ins do catch. - Requiring both axes on every cell in a one-dimensional list-style table, which produces failures for tables that legitimately have only column headers — hence the
matchesfilter for a second header level or a spanning header. - Failing the whole table when the grid is still filling in, instead of returning
undefinedwhilearia-busy="true"and letting the result land inincomplete. - Reporting the table element as the only target, so a developer receives “this table is wrong” with no coordinates and no related nodes to click through to.
- Assuming
scope="colgroup"associates a header with the columns acolspanvisually covers; it applies to the columns of a<colgroup>, which most report markup never declares.
FAQ
Does adding headers and id to every cell not bloat the markup enormously?
It does, which is why the rule only fires on tables with a second header level or a spanning header. For a simple table, scope on each header is correct, smaller and better supported, and the built-in rules already cover it. For a genuinely two-dimensional table the verbosity is unavoidable, but it is also generated: the component that renders the table emits the ids and the headers lists from the same data structure that produces the columns, so the cost is one loop rather than per-cell authoring.
How does this relate to aria-labelledby on the table itself?
aria-labelledby on a <table> supplies the accessible name and satisfies the naming check, and it is the right mechanism when a visible <h3> already titles the table and duplicating it in a <caption> would read twice. What it cannot do is associate headers with cells: the name of the table and the relationships inside it are separate criteria, and only the second one makes the numbers meaningful.
Should the check report each orphaned cell as a separate violation?
No — one violation per table with each orphan as a related node is the right shape, because the fix is one change to the code that generates the table rather than 106 individual edits. axe’s data model supports this directly: the rule fails once for the table node, relatedNodes carries the cells, and the message carries the first few coordinates so the pattern is visible without opening the artifact.
Related
- Component-Specific Rule Writing — the parent guide on the check and rule split, impacts and message wording.
- Writing Axe Rules for Web Components and Shadow DOM — the sibling case where the hard part is tree boundaries rather than grid geometry.
- Custom Rule Development & Context-Aware Testing — the section covering authoring, timing, testing and distribution of custom rules.