( Perceivable / WCAG 1.3.1 )

Table is missing header cells

SeriousLevel AWCAG 1.3.1 — Info and Relationships

What is this issue?

A <table> element holds tabular data but contains zero <th> elements anywhere in its structure, and carries no role attribute that might otherwise change how it’s interpreted. Every cell, including the ones a sighted user would immediately recognize as column or row labels, is marked up as a plain <td>.

This is the most basic table-header failure: the table hasn’t just gotten a header’s association wrong, it has no header cells to associate in the first place. It’s typically the result of a table built with visual formatting alone (bold text, a shaded background on the first row) standing in for real header markup.

Why does this matter?

As a screen reader user moves through a data table cell by cell, the standard behavior is to announce the relevant column and row header alongside each cell’s value, so every number or label arrives with context: “Q3, Revenue, $2.4M” instead of just “$2.4M.” That announcement is entirely dependent on <th> elements existing in the table for the screen reader to identify and read back.

With zero <th> elements, that context never gets generated, no matter how the table looks visually. A sighted user glances up to the bold row at the top of the table to understand what a number means; a screen reader user gets no equivalent shortcut and has to move their cursor back to that row manually, for every single cell, to reconstruct the same context a header would have given them automatically.

Who is affected?

  • Screen reader users: hear every cell’s raw value with no column or row context, and have to navigate back to the visual header row manually, cell by cell, to reconstruct meaning a properly marked-up table would announce automatically.
  • Cognitive disabilities: users who depend on the automatic header announcement to avoid holding row and column context in memory while reading a table lose that support entirely, making any table beyond a couple of columns much harder to follow.

What users experience

Yusuf uses NVDA on Windows to review a staff directory table built entirely with <td> cells; the top row is bold and shaded through CSS but uses no <th> elements at all. As NVDA moves cell by cell through the table using table-navigation commands, it reads “Sarah,” then “Designer,” then “Remote,” with no column names announced alongside any of them. Yusuf has to move his cursor back up to the first row before reading each new column, confirm which bold cell he’s currently under, then move back down to resume, turning a quick scan into a slow, repetitive back-and-forth for a table a sighted user reads in seconds.

How do I fix it?

Convert every cell that functions as a column or row label into a <th> element, and add a scope attribute stating whether it applies to a column or a row. This works because <th> is the element that carries header semantics to assistive technology; no visual styling, however clear it looks to a sighted user, produces the same effect, and scope removes any ambiguity about which direction the header applies.

Start with the header row, since it’s almost always present even when marked up incorrectly: change every <td> in that row to <th scope="col">. If the table also has a header column (row labels running down the left edge, like product names in a pricing table), change those to <th scope="row"> as well. Wrap the header row in a <thead> and the data rows in a <tbody> if the table doesn’t already use them; it’s not strictly required for headers to work, but it makes the table’s structure clearer for both browsers and future maintainers.

Code Examples

Before
<table>
  <tr>
    <td>Name</td>
    <td>Role</td>
    <td>Work location</td>
  </tr>
  <tr>
    <td>Sarah</td>
    <td>Designer</td>
    <td>Remote</td>
  </tr>
</table>
After
<table>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Role</th>
      <th scope="col">Work location</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Sarah</td>
      <td>Designer</td>
      <td>Remote</td>
    </tr>
  </tbody>
</table>

Every cell in the original header row changed from <td> to <th scope="col">, with no change to the visible text or the data rows underneath. That change alone gives NVDA, JAWS, and VoiceOver something to announce as each data cell is read: “Name, Sarah,” “Role, Designer,” “Work location, Remote,” instead of three unlabeled values in a row.

Common Mistakes

Mistake: “The header row is bold and shaded, so it’s visually obvious which cells are headers.” Bold weight and background shading are CSS properties applied to a <td>’s appearance; neither has any effect on the accessibility tree, which only recognizes header semantics from the <th> element (or an explicit ARIA header role). A table can look exactly right to a sighted user and still have zero real headers underneath.

Mistake: “This table doesn’t really need headers because there are only two columns.” Column count doesn’t change whether the announcement gap exists; even a simple two-column table benefits from <th> elements, since a screen reader user still hears “value, value” with no labels instead of “Name, Sarah” without them. The smaller the table, the less severe the cost, but the fix is exactly as cheap regardless of table size, so there’s little reason to skip it.

Mistake: “Adding th without scope is enough, since most screen readers figure out simple tables from position anyway.” Most screen readers do make a reasonable guess for a simple table from <th> position alone, but that guess isn’t guaranteed across every screen reader and table layout, and it breaks down entirely for anything beyond the simplest single-header-row structure. Adding scope is a small addition that removes the guesswork rather than relying on it.

How RedFlag Detects This

Custom automated: RedFlag’s own DOM detector, runs on every scan. RedFlag inspects every <table> element on the page and flags any with zero <th> descendants and no role attribute. It checks only for the total absence of header cells; it does not evaluate whether any existing <th> has a correct scope, since that’s a separate, more granular check.

This detector predates two more precise axe-core checks, Table cell headers attribute references a missing id and Table header has no associated data cells, that now cover header-association problems in more detail. RedFlag’s rule catalogue marks this check as superseded by those two: when a table triggers this custom check, the same table is likely to also trigger one of the axe-core checks, and RedFlag’s dashboard deduplicates the overlapping findings into a single reported issue per table rather than showing three separate table-header violations for one problem.

False negative: because the check exempts any table carrying a role attribute, a custom ARIA grid built from <table> markup with role="grid" (or any other role) but still no <th> elements is never evaluated by this specific detector, even though it has the identical underlying problem. It also doesn’t evaluate scope correctness at all: a table with <th> elements present but pointing at the wrong cells passes this check and needs the separate, more granular checks to catch it. False positive: none typical, since checking for zero <th> descendants is a straightforward structural count. Manual step: for any flagged table, confirm the fix added scope (not just bare <th> tags), and separately audit any table that uses a role attribute, since this check skips those entirely.

Manual Testing

  1. Open the page in Chrome or Firefox with NVDA or JAWS running on Windows.
  2. Navigate into the table using table-navigation commands (NVDA/JAWS: Ctrl+Alt+Arrow keys to move cell by cell).
  3. Listen to whether a column or row label is announced alongside each cell’s value, or just the raw value alone.
  4. If every cell announces with no header context whatsoever, inspect the markup in browser dev tools to confirm whether <th> elements exist anywhere in the table.
  5. If <th> elements do exist but announcements still seem inconsistent, check scope values on each one; that’s a separate, narrower issue from total absence of headers.

1.3.1 Info and Relationships: Information, structure, and relationships conveyed through presentation must also be available programmatically. A visually obvious header row or column that uses no <th> markup is a direct failure of this criterion: the relationship between labels and data is clear on screen but entirely absent from the structure a screen reader reads.

Table cell headers attribute references a missing id and Table header has no associated data cells are the more precise axe-core checks that supersede this custom detector: once a table has real <th> elements, these two rules catch the finer-grained association problems this rule can’t see.

Table header scope value is invalid is the next step after adding <th> elements: getting the scope value itself right.

Complex data table is missing a caption commonly needs fixing alongside this rule, since a table missing both headers and a caption gives a screen reader user no orientation at all: neither what the table is, nor what each cell in it means.

References

Frequently asked questions

Is a table with th elements but no scope attribute still better than one with no th at all?

Yes, considerably. Most screen readers can infer a simple header relationship from position alone once th elements exist, even without an explicit scope, though adding scope removes any ambiguity and is the more reliable fix.

Does using CSS to bold the first row instead of th technically satisfy this rule?

No. Bold styling only changes the visual weight of the text; it has no effect on the accessibility tree, which only recognizes an element as a header when it is an actual th (or carries role="columnheader"/role="rowheader"). A visually bold first row with plain td cells still fails this rule.

Why does this rule check for total absence of th, separately from checking scope correctness?

Because a table with zero th elements is a more basic, more severe failure than a table with th elements that have a wrong or missing scope. Splitting the checks means a table missing headers entirely gets flagged distinctly from a table that has headers but needs its associations refined.

Does this rule apply to layout tables used purely for visual positioning, not real data?

A table used purely for layout should ideally not be a table element at all, and should carry role="presentation" if it must remain one for legacy reasons. A genuine layout table marked with role="presentation" is correctly excluded from this check, since it is not presenting itself as a data table.

If my table uses an ARIA grid pattern with div elements instead of native table markup, does this rule still apply?

RedFlag current custom detector for this rule specifically checks for a table element with zero th descendants and no role attribute, so a div-based ARIA grid is not evaluated by this particular check. That does not mean it is exempt from the underlying requirement: a custom grid still needs equivalent header semantics through ARIA roles.