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.

A data cell with no header in the group axis A five-column table has Q1 and Q2 group headers each spanning two columns, a second header row of Revenue and Growth, and two data rows for North and South. The Growth cell under Q1 is shaded rose because scope on the group header associates it with one column only, leaving that cell without a quarter. Two header levels, one unreachable group label Region Q1 (colspan 2) Q2 (colspan 2) Revenue Growth Revenue Growth North 4.2 3% 4.6 5% South 2.8 1% 3.1 2% R3C3 announces "North, Growth, 3%" and never names the quarter scope="col" claims one grid column, so a group level needs headers and id instead.
Only the grid position reveals the defect: the cell has a row header and a column header, but nothing associates it with the quarter it belongs to.

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'],
};
How the check resolves headers for one data cell The pipeline builds a coordinate grid, then asks whether the cell has a headers attribute. If yes, each id is validated against the table. If no, the check scans left along the row and up the column. Both paths classify each header by axis, then either pass or fail with grid coordinates. Header resolution for one data cell 1 build the grid 2 has a headers attribute? 3 validate every id 4 scan row left, column up 5 classify each header yes no row + column header present: pass either axis empty: fail with coordinates
The two resolution paths converge on the same axis classification, so a table that mixes explicit and implicit cells is judged consistently.

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.

Defects found on one fourteen-column report table Four defect classes compared between the built-in table rules and the custom grid-walking rule. Group header not associated: zero versus 84. Data cell with no row header: zero versus 22. Headers attribute targets a non-th element: six versus six. Table has no accessible name: zero versus one. One fourteen-column report table, 220 body rows built-in rules custom grid rule Group header not associated with cells 0 84 Data cell with no row header at all 0 22 headers attr targets a non-th element 6 6 Table has no accessible name 0 1 The six shared findings are the ones a local property assertion can see.
The custom rule finds a superset, and the extra 106 findings are all instances of the same two authoring mistakes repeated across the table.

Edge Cases and Conditional Guards

  • rowspan="0" and omitted stub cells. A rowspan of 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 reading cellIndex.
  • Nested and layout tables. A <table> inside a <td> produces rows that appear in the outer table’s rows collection, so the builder filters on row.closest('table') === table and the rule’s matches excludes role="presentation" and role="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 from div elements has no rows collection at all, so this check does not apply to it; write a sibling check that reads aria-rowindex and aria-colindex instead. 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.cellIndex as the column position, which ignores every colspan and rowspan above the cell and silently misaligns the whole grid.
  • Treating a headers attribute 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 matches filter for a second header level or a spanning header.
  • Failing the whole table when the grid is still filling in, instead of returning undefined while aria-busy="true" and letting the result land in incomplete.
  • 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 a colspan visually 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.