F91: When Table Headers Aren't Marked Up for Screen Readers
Marcus · AI Research Engine
Analytical lens: Operational Capacity
Digital accessibility, WCAG, web development
AI-assisted · Source-linked · Editorially reviewed · Methodology
Trust note
This article was drafted with AI assistance, reviewed against accessibility.chat editorial standards, and should be treated as research and education rather than legal advice. We prioritize primary sources and correct material errors.

Using <td> for everything looks identical in a browser. For screen reader users navigating data tables, it collapses the entire information structure. That's the quiet, widespread problem documented in WCAG Failure F91 (opens in new window).
The Failure
F91 (opens in new window) describes a specific, common pattern: data tables that use <td> elements for every cell — including the header row — instead of the semantically correct <th> element (or equivalent ARIA roles like columnheader / rowheader). The failure targets WCAG 2.1 Success Criterion 1.3.1: Info and Relationships (opens in new window), which requires that structure conveyed visually also be programmatically determinable.
Here's the failing pattern, directly from the W3C document:
<table> <tr> <td>Name</td> <td>Age</td> <td>Height (cm)</td> <td>Weight (kg)</td> </tr> <tr> <td>Linda</td> <td>33</td> <td>169</td> <td>59</td> </tr> <tr> <td>Jack</td> <td>37</td> <td>184</td> <td>74</td> </tr> <tr> <td>Kira</td> <td>8</td> <td>120</td> <td>21</td> </tr> <tr> <td>Daniel</td> <td>3</td> <td>79</td> <td>14</td> </tr></table>Visually, this renders as a clean table. "Name," "Age," "Height," "Weight" sit at the top, and the data rows follow. A sighted user understands the structure instantly. But the HTML communicates no structural difference between the header row and the data rows. Every cell is just a <td>.
Why This Matters
Screen readers navigate tables cell by cell, row by row. When a user moves through data, the assistive technology is supposed to announce the associated header — "Age: 33" or "Weight: 74 kg" — so the value has context. That mechanism depends entirely on the browser's accessibility tree recognizing which cells are headers.
With all <td> elements, that relationship doesn't exist. A screen reader user navigating to the cell containing "59" hears: "59." That's it. Is that a weight? A height? An age? Without the header association, the number is meaningless. The user must either memorize the column order from the start of the table or navigate back to the first row repeatedly to reconstruct context — a significant cognitive and navigational burden.
For longer or more complex tables — financial data, comparison charts, scheduling grids — this failure doesn't just create friction. It makes the content functionally inaccessible. The data is present in the DOM, but the relationships that make it intelligible are absent.
The broader question this raises: how many tables on production systems have this exact pattern, introduced by developers who didn't know <th> existed, or by WYSIWYG editors that output flat <td> markup? The pattern is more common than most teams realize, and automated testing tools catch it inconsistently (opens in new window) — our research on testing methodology gaps shows automated tools miss a significant percentage of structural failures that require semantic context to detect.
The Fix
The correction is straightforward. Mark header cells with <th> and use the scope attribute to make the relationship explicit:
<table> <tr> <th scope="col">Name</th> <th scope="col">Age</th> <th scope="col">Height (cm)</th> <th scope="col">Weight (kg)</th> </tr> <tr> <td>Linda</td> <td>33</td> <td>169</td> <td>59</td> </tr> <tr> <td>Jack</td> <td>37</td> <td>184</td> <td>74</td> </tr> <tr> <td>Kira</td> <td>8</td> <td>120</td> <td>21</td> </tr> <tr> <td>Daniel</td> <td>3</td> <td>79</td> <td>14</td> </tr></table>The scope="col" attribute tells the browser — and the accessibility tree — that each <th> applies to all cells in its column. For tables with row headers (the first cell of each data row identifying the row), use scope="row". For complex tables with both, headers and id attributes allow explicit cell-to-header mapping.
The relevant success criteria and their compliance implications:
| WCAG Criterion | Level | Requirement | Failure Pattern | Fix |
|---|---|---|---|---|
| 1.3.1 Info and Relationships (opens in new window) | A | Structure conveyed visually must be programmatically determinable | <td> used for header cells | <th scope="col/row"> |
| 1.3.1 (complex tables) | A | Header associations must be explicit for multi-level headers | Missing headers/id linkage | id on <th>, headers on <td> |
| 4.1.2 Name, Role, Value | A | UI components must expose name, role, state | Table role not conveyed to AT | Correct semantic elements or ARIA roles |
For ARIA-based implementations, role="columnheader" and role="rowheader" are valid alternatives, but native HTML semantics should be the first choice. ARIA roles require more careful implementation and offer no advantage over <th> in standard table contexts.
Applying This
In code review: Flag any <table> element and check whether the first row (or first column) uses <th> with a scope attribute. If the header row is all <td>, that's an immediate F91 failure. This is a one-line check that reviewers can apply in seconds.
In automated testing: Tools like axe-core and Lighthouse will catch some instances of this failure — specifically when a table has no <th> at all. But they may not catch cases where <th> is used without scope on complex tables, or where the table structure is ambiguous. Treat automated results as a floor, not a ceiling. Our research on hybrid testing approaches makes clear that structural failures like this one require human verification to catch consistently.
In design systems: If your component library includes a table component, build <th> and scope into the default output. Make the accessible pattern the path of least resistance. A table component that outputs all <td> is shipping an accessibility defect by default.
In CMS and WYSIWYG environments: Many content editors generate flat <td> markup even when users visually format a header row. If your platform uses a rich text editor for table creation, test its output. You may need a post-processing step or a custom table block that enforces correct markup.
Quick audit step: Run this CSS snippet in your browser's devtools on any page with tables — it visually highlights <td> cells in the first row, which are likely F91 violations:
table tr:first-child td { outline: solid red;}Any red-outlined cell in a data table's first row is a candidate for remediation.
CORS Perspective
From an operational capacity standpoint, F91 sits in a useful category: high impact, low remediation cost. The fix is a tag change — <td> to <th scope="col"> — and it's automatable in most codebases through a targeted find-and-replace or linting rule. The real operational challenge isn't fixing existing tables; it's preventing the pattern from recurring through CMS output, third-party components, and developer habits. Teams that address F91 at the component and tooling level — rather than as a one-time audit finding — are the ones that don't see it resurface. That's the difference between a patch and a process, and it's where compliance sustainability actually gets built.
About the Marcus lens
An operational lens on digital accessibility. Frames findings around what implementation and maintenance actually require — WCAG conformance, engineering effort, and day-to-day web development practice.
Marcus is an AI analyst lens, not a human staff member. It helps frame this article through a consistent accessibility perspective.
Specialization: Digital accessibility, WCAG, web development
View all articles using this lens →Primary source reviewed: https://www.w3.org/WAI/WCAG22/Techniques/failures/F91 (opens in new window)
Transparency Disclosure
This article was drafted with AI assistance and reviewed against our editorial methodology. We disclose that process so readers can judge the work clearly.