Load this skill whenever the project contains HTML data tables (<table> elements). Under no circumstances use tables for layout purposes. Absolutely always include <th> elements with appropriate scope attributes, a <caption> or aria-labelledby, and ensure complex tables have headers associated with data cells. Apply these rules to every data table without exception.
Canonical source: examples/TABLES_ACCESSIBILITY_BEST_PRACTICES.md in mgifford/ACCESSIBILITY.md
This 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.
Core Mandate
Use tables for information whose meaning depends on relationships between
rows and columns. Mark those relationships in HTML so users can identify the
table, navigate its cells, and hear the relevant headers with each value. The
complexity of the markup should match the complexity of the information —
simplifying or splitting a table is often better than adding increasingly
complicated header associations.
Never use tables for layout. Use CSS Grid/Flexbox instead. Do not turn a
reading-oriented data table into an application-style grid (role="grid")
unless users genuinely need spreadsheet-like keyboard interaction.
Severity Scale (this skill)
Level
Meaning
Critical
Table data is completely uninterpretable by screen reader users
Serious
Headers missing or misassociated; complex table with no navigation aid
Moderate
No name for the table when multiple tables share a page; zebra stripes lost in forced-colours without fallback
Minor
<thead>/<tbody> absent; obsolete summary attribute used
Choose the Correct Structure First
A list — items that don't depend on column relationships
A definition list — term/description pairs
A data table — reading and comparing row-column data
An ARIA grid/treegrid — only for an application-style composite widget
with cell navigation or editing
CSS Grid/Flexbox — page layout
Do not use a table merely to align form fields, cards, images, or page regions.
Critical: Every Data Table Must Have <th> with scope
A table with only <td> cells is Critical — screen readers announce raw
data with no context. Use <th> for cells that label a row, column, or
group — never a bolded <td> as a visual substitute.
For a small, unambiguous table with only one header row or column, browsers
can sometimes infer header direction from <th> alone. Explicit
scope="col"/scope="row" remains the dependable authoring convention,
especially for large or similar-looking data. <thead>/<tbody>/<tfoot>
support sticky headers, styling, scripting, and repeating headers in print —
their presence does not by itself repair missing or incorrect header associations.
When a header row has an empty top-left corner cell (no header function),
leave it as <td> — don't add filler text like "blank" just to occupy the cell.
Moderate: Captions and Table Names
<caption> is the native way to identify a table, especially with several
tables on one page. WCAG does not require a caption in every case — an
immediately associated heading or surrounding text can sometimes adequately
identify a simple table. Treat <caption> as the preferred default, but
don't force a duplicate name when another tested association already
supplies one.
<table>
<caption>2025 budget allocation by department</caption>
<!-- header and data rows -->
</table>
The caption must be a direct child of <table> (before <thead>), concise
and specific. For unusual structure, a short summary can explain row/column
organization without repeating every value:
<caption>
Availability of holiday accommodation
<span class="table-summary">Locations form row groups. Property types form columns.</span>
</caption>
The obsolete HTML summary attribute on <table> should not be used — use
visible caption content or a nearby description instead.
Use colgroup/rowgroup scope values only when the corresponding group
actually exists in the table structure (matching <colgroup> or <tbody>
row group):
For row groups spanning several rows, use scope="rowgroup" with rowspan
on the group header. Test grouped headers with the project's actual AT
combinations — if associations are difficult to understand, split into
simpler tables rather than adding more complexity.
Serious: When to Use headers + id (Rarely)
Use only for tables so complex that scope causes headers to apply to the
wrong cells:
Every ID must be unique in the document; every headers token must match a
real header cell. Before reaching for headers/id, consider: splitting
one table into several; transposing rows and columns; moving explanatory
material outside the grid; reducing nested header levels; offering a
filtered view. Even when technically correct, a table needing three or four
headers read per cell may be functionally inaccessible in practice — per
WebAIM, multiple levels of row/column headers are unlikely to be functionally
understandable to a screen reader user. Validate headers/id associations
after every schema or column change.
Serious: Empty, Missing, and Special Values
An empty cell is ambiguous — it can mean zero, not applicable, unknown, not
reported, or intentionally blank. Use explicit text where the distinction
matters:0 for a measured zero; Not applicable; No data; Not reported; Suppressed for intentionally withheld values. Define shortened
symbols (N/A, em dash) in the caption or a nearby note; prefer full text
when space permits. Do not use to make an empty cell appear
occupied. Explain estimated/rounded/preliminary/revised values — don't rely
on colour or typographic style alone to identify them.
Serious: Totals, Subtotals, and Footers
Mark total labels as headers when they identify the row's values:
Do not communicate subtotals only through bold text, indentation, or
background colour — include visible text ("Subtotal") and preserve header
associations. If a total is calculated dynamically, expose update status
appropriately and ensure the exported value uses the same calculation.
Moderate: Responsive Tables and Reflow
WCAG 1.4.10 allows two-dimensional scrolling for content that genuinely
requires it, including data tables — the exception applies to the table
itself, not the surrounding page.
Use a labelled region when separate discoverability genuinely helps — don't
turn every table wrapper into a landmark. Use tabindex="0" to make overflow
keyboard-scrollable, with a visible focus indicator.
Avoid responsive CSS that changes table/thead/tbody/tr/th/td to
display: block — browser/AT behavior varies and duplicated data-label
content can drift out of sync with the real headers. When a card presentation
is genuinely better on small screens, render a real alternative list or
definition-list view from the same data — expose one view at a time, preserve
all values and controls, and test both presentations. Test at 200%/400% zoom,
long translated strings, and narrow viewports.
Keep the original semantic <th> elements — do not create focusable cloned
headers. If a visual clone is unavoidable, hide it from AT and remove any
duplicate IDs. Ensure sticky content doesn't obscure focus, headings, or the
first data row. Test at high zoom and with text-spacing overrides.
Moderate: Sortable Columns
Put a native button inside each sortable column header; put aria-sort on
the currently-sorted <th>only (not the button, not both):
<th scope="col" aria-sort="ascending">
<button type="button">
Last name
<span class="sort-icon" aria-hidden="true">▲</span>
</button>
</th>
When sorting changes: sort the complete dataset, not only the visible page,
unless clearly stated otherwise; move aria-sort to the newly sorted <th>;
use ascending/descending/other accurately; update the visible direction
indicator without depending on colour alone; keep keyboard focus on the
activated button; preserve header/data-cell associations. Do not replace the
button with a click handler on <th> itself.
Update the visible result count after filtering; use role="status" when it
changes without moving focus; keep active filters visible and
programmatically determinable; provide a clear way to remove filters; make
"No results" visible and announced. Do not announce every changed cell
through a live region. Keep the caption accurate when scope/date range changes.
Moderate: Pagination and Large Datasets
Pagination is often more robust than rendering or virtualizing thousands of rows.
Identify the current page with aria-current="page"; use links for
URL-based navigation, buttons for in-place state changes; preserve sort/filter
parameters; state the displayed range and total ("Rows 51 to 100 of 842");
move focus only when predictable and helpful. "Load more" controls need a
clear name, a result update, and predictable focus.
Virtualization (rendering only part of a dataset while scrolling) can
break find-in-page, table navigation, row counts, focus, and reading
continuity — prefer pagination when it meets the task. If virtualization is
necessary: expose accurate total row/column counts; expose correct indices
for rendered items; never recycle the focused row out from under the user;
preserve selection/editing state when items leave the DOM; keep headers
correctly associated; support zoom/text resizing/variable row height; test
continuous reading with screen readers, not just visual scrolling. Do not
claim thousands of rows are accessible merely because aria-rowcount is present.
Serious: Row Selection and Row Actions
Do not make an entire <tr> an unnamed clickable control — put a real
checkbox, link, or button in a cell:
The checkbox's computed name becomes "Select invoice 1042." Keep selected
state programmatically available and visually distinguishable without colour
alone. For repeated row actions, include the row's identifier in each
accessible name — avoid dozens of links announced only as "View" or "Edit."
Serious: Forms and Editing in Tables
Every input needs a unique label including enough row/column context:
Associate validation errors with the specific input; don't remove the input
from its row context on error; preserve user-entered values after
validation; make save/cancel/undo explicit; don't autosave on focus movement
unless users are warned and can recover. If users need spreadsheet-like
navigation/editing across many cells, evaluate an ARIA grid rather than
bolting ad hoc keyboard handling onto a data table.
Moderate: Expandable Rows
<tr>
<th scope="row">Invoice 1042</th>
<td>$1,240</td>
<td>
<button type="button" aria-expanded="false" aria-controls="invoice-1042-details">
Show details for invoice 1042
</button>
</td>
</tr>
<tr id="invoice-1042-details" hidden>
<td colspan="3">
<h3>Invoice 1042 details</h3>
<p>Issued 4 March 2025. Payment is due 3 April 2025.</p>
</td>
</tr>
Update aria-expanded, button text, and hidden together. Keep focus on the
button unless the user's action clearly requests movement into the disclosed content.
Moderate: Colour, Contrast, and Forced Colours
Zebra striping is supplemental, not sufficient on its own. Do not use row
colour as the only indication of overdue/selected/invalid/changed data — add
visible text, an icon, or a pattern. Ensure table text and required non-text
elements (focus indicators, form-control boundaries, sort/state icons) meet
applicable contrast requirements. Decorative cell borders and zebra stripes
don't automatically need 3:1.
Test selected rows, invalid cells, focus, hover, sorting, and sticky headers
in every supported theme and forced-colours mode.
Data Tables vs. ARIA Grids
A native <table> supports reading/comparison and normally doesn't place
cells in the Tab sequence — screen readers provide their own table
navigation. role="grid" is a composite widget creating expectations for
managed focus, arrow-key navigation, selection, editing, and exposed
row/column structure. Use a grid only when: users need spreadsheet-like
cell navigation/editing; the interaction model is documented and completely
implemented; only the intended cell/descendant is in the Tab sequence; arrow
keys/Home/End work predictably; row/column positions and states are
accurate; it's been tested with relevant AT. Do not add role="grid" just
to make a table sound more interactive — sort/filter buttons alone don't
make a table a grid. Prefer native <table>.
Layout Tables — What to Do With Them
Remove and use CSS whenever practical. When a legacy layout table can't yet
be removed:
Do not use <th>, <caption>, or table-specific navigation inside it;
ensure content has a meaningful DOM order read linearly; ensure forms/
headings/lists retain their own native semantics; don't make the table
focusable or give it table-specific ARIA. role="none" is equivalent to
role="presentation" here. Treat this as temporary remediation, not a new
layout pattern.
For exported PDF/spreadsheet/document/presentation files: preserve table
structure and header associations; include a title/caption; repeat headers
across pages/sheets; preserve units/filters/source/date range; explain
special values and abbreviations; test the exported format independently
with its own accessibility tools. Accessible HTML does not guarantee an
accessible export — test and remediate each output format separately.
CMS and Framework Notes
Drupal: CKEditor's table plugin should be configured to require
<caption> and scope attributes. The
Drupal Accessibility Coding Standards
require WCAG 2.1 AA for contributed modules/themes.
WordPress/other CMS: Block editors often generate tables without
<caption> or scope — audit CMS-generated markup and configure the editor
or post-process output to add missing attributes.
Generated tables (data-grid libraries, DataTables.js, AG Grid): verify
the library outputs <th scope="col">, <caption>, and <thead> — many
don't by default. Configure or test the exact version to confirm authors can
create real header cells, captions/summaries can be added, scope/headers/
unique IDs are preserved, sanitization doesn't strip required attributes, and
exported formats preserve structure.
Testing
Structural: confirm the content is genuinely tabular; identify every
row/column/row-group/column-group header; from each data cell, verify the
headers a user needs; confirm scope/headers produces those exact
associations; check captions, units, source, special-value definitions;
validate IDs and headers references after sorting or re-rendering
Keyboard/visual: operate sorting/filtering/pagination/selection/
editing/disclosures without a pointer; confirm focus stays visible and
doesn't move unexpectedly; scroll wide tables by keyboard; test 200%/400%
zoom and narrow viewports; test every theme, forced colours, print, and
exported formats
AT: navigate to the table and confirm name/dimensions are useful; move
through representative cells and verify announced headers; test grouped/
spanned/irregular headers extensively; confirm sorting state, selected
rows, expanded rows, input labels, errors, and result status
Automated: can detect some missing headers, invalid ARIA, duplicate
IDs, empty captions, contrast, and focus issues — usually cannot determine
whether a cell is a header in context, whether associations express the
intended relationship, or whether a complex table should be simplified.
Manual cell-by-cell review remains required.
Common Failures
Failure
Correction
Bold <td> elements visually imitate headers
Use semantic <th> with correct associations
"Every <th> requires scope" stated as an absolute WCAG rule
Use explicit scope as a robust pattern where direction matters; don't misstate the criterion
"Every table requires a caption for conformance" stated as absolute
Use captions by default; recognize other tested associations can identify simple tables
scope="colgroup" used without a matching <colgroup>
Define the column groups in markup
headers values no longer match current IDs
Regenerate and validate associations from the current schema
Empty cells use or unexplained dashes
State zero, not applicable, missing, suppressed, or another accurate meaning
Responsive CSS changes all table elements to display: block
Preserve table semantics; use contained overflow or a real alternative view
Every scroll wrapper becomes a region landmark
Add a named region only when separate discovery is useful
aria-sort placed on the sort button
Put it on the currently sorted <th> only
Sorting reorders only the visible page without explanation
Sort the complete result set or clearly explain scope
An entire row is clickable
Put a named native link, button, or checkbox in a cell
Repeated controls announced only as "View"/"Edit"
Include the row identifier in each accessible name
role="grid" added without a grid keyboard model
Keep a native table, or implement the complete composite widget
Virtual rows disappear while they contain focus
Preserve focus and state, or use pagination
HTML table passes but exported PDF loses headers
Test and remediate each output format separately
Definition of Done Checklist
Content requires row-column relationships and is not a layout table
Every functional header is a <th> with the correct association
Table has a useful name, normally via <caption> (or a tested alternative)
Complex structure has a concise, discoverable explanation
scope, groups, spans, IDs, and headers references match the current data
Table simplified or split where complexity impedes understanding
Empty/missing/zero/suppressed/estimated values are distinguishable
Units, dates, currencies, and abbreviations are clear
Wide tables scroll within a contained, keyboard-operable region
Responsive presentation preserves table semantics or provides a real equivalent view
Sticky headers don't duplicate semantics or obscure content
Sort state on the correct <th> only; sort controls keyboard-operable
Filtering/pagination/selection/editing/disclosures use named native controls
Colour is not the only indication of meaning or state
role="grid" used only with a complete, tested grid interaction model
Virtualization (if used) preserves counts, positions, focus, and reading continuity
Structure and relationships survive print and export
Standards 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/
Load this skill immediately whenever a project contains an ACCESSIBILITY.md file, or whenever you are contributing to the mgifford/ACCESSIBILITY.md repository. Absolutely always read ACCESSIBILITY.md before proposing or writing any changes. Under no circumstances skip this skill when an ACCESSIBILITY.md file is present. This skill governs when and how all other accessibility topic skills must be loaded.
Load this skill whenever the project contains in-page anchor links, skip links, fragment identifiers, or heading links. Under no circumstances omit accessible anchor-link requirements when such links exist. Ensure every anchor link has meaningful text, a reachable target, and a visible focus indicator. Apply these rules when creating or reviewing any in-page navigation.
Audit and fix common accessibility issues in Angular templates and Angular Material components. Use when the user mentions Lighthouse, axe, screen readers, keyboard navigation, ARIA, asks to fix a11y issues in Angular HTML templates, or after any feature, bug fix, or refactor that changed Angular templates.
Generates Angular code and provides architectural guidance. Trigger when creating projects, components, or services, or for best practices on reactivity (signals, linkedSignal, resource), forms, dependency injection, routing, SSR, accessibility (ARIA), animations, styling (component styles, Tailwind CSS), testing, or CLI tooling.
Creates a new Angular app using the Angular CLI. This skill should be used whenever a user wants to create a new Angular application and contains important guidelines for how to effectively create a modern Angular application.