slop audit
Blog post 58 / Accessibility and semantics

HTML table accessibility: markup and testing guide

Test HTML table accessibility before launch: add captions and header associations, preserve mobile access, and check deployed tables with Playwright.

By Slop Audit · 8 min read

HTML table accessibility means preserving the relationships between values and their headers so people can understand the data without relying on its visual position. Before launching a pricing comparison, course schedule, or results page, check the caption, header markup, narrow layout, and rendered table with assistive technology.

A table can look correct while every cell is exposed as ordinary text. This guide shows a small native HTML example, explains common failures, and adds a focused browser regression test. Use the broader accessibility checks before launch for the surrounding page.

HTML table accessibility checklist

  1. Use a native table for data with meaningful row and column relationships.
  2. Give the table a concise caption that identifies its subject.
  3. Mark real header cells with th, with appropriate row or column scope.
  4. Keep units and values understandable without color or position alone.
  5. Simplify merged headers where possible; associate complex headers explicitly when needed.
  6. Preserve the table structure and every value on narrow screens.
  7. Inspect the rendered roles, names, and associations after client rendering.
  8. Test representative cells with a screen reader, then keep stable assertions in CI.

Apply this to each table template and populated state. A correctly marked-up empty table does not prove that asynchronously loaded rows use the same structure.

Start with native data table markup

Use tables when the relationship between rows and columns carries meaning: for example, the number of failed checks for each deployment. Use CSS layout for positioning unrelated cards or page sections. The W3C Tables Tutorial distinguishes data tables from visual layout and explains why structural markup matters to assistive technology.

Here is a compact release-results table:

<table>
  <caption>Launch checks by deployment</caption>
  <thead>
    <tr>
      <th scope="col">Deployment</th>
      <th scope="col">Passed checks</th>
      <th scope="col">Failed checks</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Preview</th>
      <td>18</td>
      <td>2</td>
    </tr>
    <tr>
      <th scope="row">Production</th>
      <td>20</td>
      <td>0</td>
    </tr>
  </tbody>
</table>

The caption identifies the dataset. Column headers identify the two measurements, and row headers identify the deployment. A value such as 2 becomes useful when the reader can determine both its deployment and its measurement.

Keep native semantics unless an actual interaction requires something else. Adding role="grid" to a read-only comparison table introduces expectations associated with an interactive widget. The W3C Table Pattern explains the distinction between a static table and an interactive grid. Avoid role="presentation" or role="none" on a real data table because its relationships need to remain available.

Add a useful table caption

Write a caption that distinguishes this table from other tables on the page. “Launch checks by deployment” is more useful than “Table 1.” Put shared context, such as a reporting period, in the caption or nearby explanatory text instead of repeating it in every value.

A visible page heading is useful context, but it is not automatically the table's caption. The native caption element gives that relationship directly. W3C's Caption & Summary tutorial explains how captions identify tables and how additional explanations can describe complex organization.

Keep instructions separate from the name. If a wide table needs horizontal scrolling, show a short instruction near it. Do not make the table's accessible name a paragraph of operating instructions. The accessible name guide explains how to inspect the computed name when ARIA or component wrappers affect the result.

Connect values to row and column headers

Use th for headers and td for ordinary values. Making a td bold does not turn it into a header. Likewise, wrapping a row in thead does not replace the individual header cells.

For a simple rectangular table, scope="col" identifies a column header and scope="row" identifies a row header. Put scope on the th, not on its tr. W3C's two-header table examples demonstrate this arrangement for data that needs both axes.

Header text must describe the values accurately. Use “Response time (ms)” instead of an unexplained “Time.” For a plan comparison, prefer a clear value such as “Included” or “Not included” over an unlabeled checkmark or a green background. Keep the meaning available when color is absent and when the cell is read on its own with its headers.

Blank corner cells need thought. In the example, “Deployment” describes the row-header column. Avoid adding an empty th just to obtain a border or background. Also check generated rows: a component may render the first column as td even when the static prototype used row headers.

Simplify complex table headers before adding ARIA

Merged cells need more than visual alignment. If a table has irregular grouped headers, use appropriate groups and header scope. When header relationships cannot be expressed clearly through that structure, unique header IDs and a data cell's headers attribute can identify the relevant headers explicitly.

For example, a cell with headers="production failures" refers to header elements whose IDs are production and failures. Those IDs must exist in the same table and remain unique in the document. A copied component with repeated IDs can silently break this contract.

Use W3C's irregular-header examples for group scope and its multi-level header tutorial for explicit associations. Often the better release fix is to split a large matrix into smaller tables with clear captions. Do not add headers attributes indiscriminately to a simple table that already has understandable native relationships.

Preserve HTML table accessibility on mobile

Keep values reachable at narrow widths and when text is enlarged. Avoid hiding columns merely to make the screenshot fit. If a column is intentionally omitted from one view, provide a clear way to access equivalent information.

A genuinely two-dimensional data table may need local horizontal scrolling. WCAG's Reflow explanation allows an exception for content whose meaning or use requires a two-dimensional layout; the surrounding page still needs to reflow. That exception does not excuse clipped text inside individual cells.

Use the horizontal overflow CSS guide to contain a wide table in a keyboard-accessible scroll region. Check its focus indicator, scroll instructions, and access to the first and last columns. Test a real keyboard path rather than assuming a visible scrollbar is sufficient.

If a responsive component turns rows into cards, inspect the resulting accessibility tree. Preserve meaningful labels and reading order in that view. Test both presentations: a desktop table passing assertions does not establish that the mobile transformation is understandable.

Add table accessibility testing to Playwright

For a known table template, assert its name, header roles, and structural attributes after the data loads. This example targets the markup above. In a project with Playwright Test installed, save it as a test file and replace the example URL with your owned deployed route:

import { expect, test } from "@playwright/test";

test("release results preserve table structure", async ({ page }) => {
  await page.goto("https://example.com/releases");

  const table = page.getByRole("table", {
    name: "Launch checks by deployment",
    exact: true,
  });
  await expect(table).toBeVisible();
  await expect(table.getByRole("columnheader")).toHaveText([
    "Deployment",
    "Passed checks",
    "Failed checks",
  ]);
  await expect(table.getByRole("rowheader")).toHaveText([
    "Preview",
    "Production",
  ]);
  await expect(table.locator('thead th[scope="col"]')).toHaveCount(3);
  await expect(table.locator('tbody th[scope="row"]')).toHaveCount(2);

  const production = table.getByRole("row").filter({
    has: page.getByRole("rowheader", { name: "Production", exact: true }),
  });
  await expect(production.getByRole("cell")).toHaveText(["20", "0"]);
});

Playwright's role locator documentation describes selecting elements by their exposed role and name. The explicit attributes above also protect this particular markup contract. For live results, replace the fixed counts and values with stable fixture data or assertions appropriate to the release.

These checks catch a lost caption, headers rendered as ordinary cells, and an unexpected results row. They do not prove that assistive technology announces the intended header for every cell. Add an axe scan for other detectable issues, then follow Playwright's accessibility testing guidance by combining automation with manual assessment.

Before sign-off, navigate a representative table with a screen reader. Confirm that its name is useful, row and column headers match each value, and sorting or pagination preserves context when those controls exist. Test interactive controls with the keyboard separately. Keep the browser, assistive technology, viewport, route, and observed result with your website regression testing evidence.

Check the deployed page with Slop Audit

Once the table is published, run Slop Audit on the public page. Its bounded browser pass runs axe and records detected violation IDs and affected selectors, alongside mobile and desktop screenshots when Browser Run is available. Use that evidence to find detectable accessibility issues and inspect the page around the table before sharing the release.

Slop Audit does not provide a dedicated table-header association report, operate a screen reader, or verify that each caption and header describes the data correctly. Its initial browser states also do not cover every sorted, paginated, or expanded result. Keep the targeted browser test and manual cell-navigation check beside the scan; a clean automated result is evidence for the tested state, not accessibility certification.