tables — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited tables (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Canonical source:examples/TABLES_ACCESSIBILITY_BEST_PRACTICES.mdinmgifford/ACCESSIBILITY.mdThis skill is derived from that file. When in doubt, the example is authoritative.
Apply these rules when creating or reviewing HTML data tables. Only load this skill if the project contains data tables.
Tables communicate relationships between data. Sighted users scan rows and columns visually; screen reader users navigate cell by cell and rely on header announcements for context. Without proper markup, every cell is an orphaned data point.
Never use tables for layout. Use CSS (Grid, Flexbox) instead. Layout tables that remain in a codebase must have role="presentation" and must linearise without loss of meaning.
| Level | Meaning |
|---|---|
| Critical | Table data is completely uninterpretable by screen reader users |
| Serious | Headers missing or misassociated; complex table with no navigation aid |
| Moderate | <caption> missing; zebra stripes lost in forced-colours without fallback |
| Minor | <thead>/<tbody> absent; summary attribute used (deprecated) |
Tables are rendered differently across AT combinations. Test with:
| AT | Browser | Notes |
|---|---|---|
| NVDA | Chrome | Reads headers on cell entry; table navigation via T key |
| JAWS | Chrome | Similar to NVDA; Ctrl+Alt+Arrow navigates cells |
| VoiceOver | Safari (macOS) | VO+Arrow navigates; announces scope-based headers reliably |
| VoiceOver | Safari (iOS) | Swipe navigation; complex tables challenging |
| TalkBack | Chrome (Android) | Linear reading; simpler tables work better |
| Voice Control | Any | Navigation by table elements less common; focus on operability |
| Screen magnification | Any | Wide tables require horizontal scroll — use responsive patterns |
| Reader Mode | Firefox/Edge/Safari | Strips CSS; table structure must be semantically meaningful |
| Edge Read Aloud | Edge | Reads table content linearly; <caption> helps orientation |
Reader Mode note: Firefox, Safari, and Edge all have reader modes that reformat page content. Tables survive reader mode best when <caption> is present and structure is simple. Complex multi-level tables may be stripped or reflowed unexpectedly — provide a text summary for high-complexity tables.
<th> with scopeA table with only <td> cells is Critical — screen readers announce raw data with no context about what each cell means.
All `<th>` elements must have a `scope` attribute. While screen readers may infer col or row from layout, explicit scope is unambiguous and required for reliable AT support.
<table>
<caption>Monthly sales by region, Q1 2024</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">January</th>
<th scope="col">February</th>
<th scope="col">March</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">North</th>
<td>$12,400</td>
<td>$14,200</td>
<td>$16,800</td>
</tr>
<tr>
<th scope="row">South</th>
<td>$9,100</td>
<td>$10,300</td>
<td>$11,900</td>
</tr>
</tbody>
</table>scope="col" on column headers; scope="row" on row headers. When both are present, the screen reader announces both before the data cell.
<caption> on Every Data TableA table without <caption> is Moderate in isolation but Serious when multiple tables appear on one page — users cannot distinguish which table they are in. Make <caption> a universal requirement.
<table>
<caption>2024 budget allocation by department</caption>
…
</table><caption> is the first child of <table> — before <thead>. It is announced by screen readers when the user enters the table. It also helps users in Reader Mode identify the table's purpose after CSS is stripped.
scope="colgroup" / scope="rowgroup"When a header spans multiple columns or rows, use colgroup or rowgroup scope values:
<table>
<caption>Quarterly revenue by product line (USD thousands)</caption>
<thead>
<tr>
<th scope="col" rowspan="2">Product</th>
<th scope="colgroup" colspan="2">H1</th>
<th scope="colgroup" colspan="2">H2</th>
</tr>
<tr>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
<th scope="col">Q3</th>
<th scope="col">Q4</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Hardware</th>
<td>1,200</td>
<td>1,450</td>
<td>1,100</td>
<td>1,800</td>
</tr>
</tbody>
</table>Test spanned tables with NVDA+Chrome and JAWS+Chrome — some screen readers still handle complex spanned headers inconsistently. If testing reveals problems, simplify the table structure before reaching for headers/id.
headers + id (Rarely)The headers and id approach associates individual cells with specific header cells by ID reference. Use it only for tables so complex that scope causes headers to apply to the wrong cells.
<table>
<caption>Staff schedules by shift and department</caption>
<tr>
<th id="dept">Department</th>
<th id="am">AM shift</th>
<th id="pm">PM shift</th>
</tr>
<tr>
<th id="nursing">Nursing</th>
<td headers="nursing am">8</td>
<td headers="nursing pm">6</td>
</tr>
</table>Caution: Even when headers/id is technically correct, a table complex enough to need it may be functionally inaccessible — reading three or four headers before each cell is confusing in practice. Prefer simplifying the table. Per WebAIM: "If there are multiple levels of row and/or column headers being read, it will not likely be functionally accessible or understandable to a screen reader user."
<thead>, <tbody>, <tfoot>These semantic elements have no direct AT benefit on their own, but <thead> enables display: table-header-group in print CSS, repeating column headers on every printed page — important for multi-page tables.
<table>
<caption>…</caption>
<thead>
<tr><th scope="col">…</th></tr>
</thead>
<tbody>
<tr><td>…</td></tr>
</tbody>
<tfoot>
<tr><td colspan="4">Total: $45,600</td></tr>
</tfoot>
</table>Wide tables require horizontal scrolling for low-vision users who zoom. Always wrap tables in a scrollable container — never clip overflow silently:
<!-- Scrollable container with accessible label -->
<div role="region"
aria-labelledby="table-caption-id"
tabindex="0"
style="overflow-x: auto;">
<table>
<caption id="table-caption-id">Monthly sales by region</caption>
…
</table>
</div>tabindex="0" makes the scroll container keyboard-focusable so keyboard users can scroll it. role="region" + aria-labelledby announces it as a landmark.
For small screens, consider a card-based alternative layout via CSS:
@media (max-width: 600px) {
table, thead, tbody, th, td, tr { display: block; }
thead tr { position: absolute; top: -9999px; left: -9999px; }
td::before {
content: attr(data-label) ": ";
font-weight: bold;
}
}When using the card pattern, add data-label attributes to each <td>:
<td data-label="Region">North</td>
<td data-label="January">$12,400</td>Interactive sortable columns must be keyboard operable and announce state:
<th scope="col">
<button type="button"
aria-sort="ascending"
aria-label="Sort by Region, currently ascending">
Region
<svg aria-hidden="true" focusable="false"><!-- sort icon --></svg>
</button>
</th>aria-sort values: ascending, descending, none, other. Place aria-sort on the <th>, not the <button> — or on both if needed for maximum AT compatibility. Test with NVDA and JAWS; announcement varies.
Zebra stripes (alternating row backgrounds) are a common pattern that vanishes in forced-colours mode and when printing with backgrounds off:
@media print {
tbody tr { border-bottom: 1px solid #333; }
}
@media (forced-colors: active) {
tbody tr { border-bottom: 1px solid CanvasText; }
}Never use background colour alone to convey meaning (e.g., red rows = overdue). Always pair colour with a text label or icon.
Layout tables in legacy codebases must be marked to remove them from table navigation mode:
<table role="presentation">
<!-- layout content -->
</table>role="presentation" removes table semantics from the AT tree. The content must still linearise logically when read top-to-bottom, left-to-right. Verify by disabling CSS and reading the page linearly.
Drupal: The CKEditor rich text editor in Drupal includes a table plugin. Configure it to require <caption> and scope attributes via editor configuration. The Drupal Accessibility Coding Standards require WCAG 2.1 AA compliance for contributed modules and themes — table markup in contrib must follow these rules.
WordPress, other CMS: Block editors often generate tables without <caption> or scope. Audit CMS-generated table markup and configure the editor or post-process the output to add missing attributes.
Generated tables (charts/dashboards): When JS libraries generate tables from data (e.g., DataTables.js, AG Grid), verify the library outputs <th scope="col">, <caption>, and <thead>. Many do not by default — check configuration options.
role="presentation"<caption> as its first child<th> elements have explicit scope attribute (col, row, colgroup, or rowgroup)<thead> present; <tbody> presentcolgroup/rowgroup scope valuesheaders/id used only where scope is genuinely insufficientrole="region" + aria-labelledby + tabindex="0" scrollable containeraria-sort on <th>; sort controls keyboard-operabledata-label attributes added for responsive card layoutStandards horizon: These rules target WCAG 2.2 AA. No significant changes to table accessibility requirements are anticipated in WCAG 3.0. Monitor: <https://www.w3.org/TR/wcag-3.0/>
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.