Table Header Without Scope Or Id

ID

html.th_id_or_scope

Severity

high

Remediation Complexity

trivial

Remediation Risk

low

Remediation Effort

low

Resource

Reliability

Language

Html

Tags

accessibility, reliability

Description

Flags a cell of a data table whose header association cannot be resolved: a <th> that declares neither scope nor id, and a <td> that declares scope, which is a header-cell attribute the browser ignores there.

Rationale

A header cell only helps once a reader can tell which cells it heads. HTML offers two ways to say it: scope on the header (col, row, colgroup, rowgroup), or an id on the header that data cells name in their headers attribute. With neither, the association falls back to the browser’s default - the first row is column headers, the first cell of a row is that row’s header.

That default is the right answer for a header in either of those two places, so a header there is not reported: asking a simple table to spell out what the browser already resolves correctly is noise, and it is the overwhelming majority of the <th> on any page. Only a header the default cannot reach accuses — the second row of a two-level heading, a header in the middle of a row — and one finding per table, on the first such header, because the fix is a single pass over the whole table’s heading.

<table>
  <tr><th scope="col">Name</th><th scope="col">Quarter</th></tr>
  <tr><td>Ada</td><th>Q1</th></tr>                    <!-- FLAW - no scope, no id, out of reach -->
</table>

<table>
  <tr><th scope="col">Name</th><th scope="col">Quarter</th></tr>
  <tr><td>Ada</td><th scope="">Q1</th></tr>           <!-- FLAW - blank is the undefined state -->
</table>

<table>
  <tr><th scope="col">Name</th><th scope="col">Role</th></tr>
  <tr><td scope="row">Ada</td><td>Owner</td></tr>     <!-- FLAW - scope is th-only, does nothing here -->
</table>

<table>
  <tr><th>Name</th><th>Role</th></tr>                 <!-- OK - the first row is the default's answer -->
  <tr><th>Ada</th><td>Owner</td></tr>                 <!-- OK - so is the first cell of a row -->
  <tr><td>Ada</td><th scope="row">Q1</th></tr>        <!-- OK -->
  <tr><td>Ada</td><th id="q1">Q1</th></tr>            <!-- OK - data cells point back with headers -->
  <tr><td>Ada</td><th th:scope="${axis}">Q1</th></tr> <!-- OK - computed, but declared -->
  <tr><td th:scope="${axis}">Ada</td></tr>            <!-- OK - a directive, not markup this file judges -->
</table>

Either spelling of the attribute answers for it: a th:scope directive does put a scope on the rendered page, and an exemption that missed it would be a false positive. A value settled at render time counts as declared for the same reason. What does not answer is a valueless or blank attribute - that is the attribute’s undefined state, and the browser falls back exactly as if it were absent.

scope on a <td> is the mirror-image mistake, and gets its own message. The attribute is defined on <th> only; on a data cell it does nothing whatever its value, and it is written when the cell was meant to be a header in the first place. Only the plain spelling accuses - a th:scope directive on a cell is the template library’s business - and the value is not read, since none of them has any effect there.

Only the cells of a data table are judged. A <th>, or a stray scope, in a table declared role="presentation" is a finding of html.layout_table_semantic_markup instead, so the two rules never both report the same markup. The table shapes neither rule judges are the ones described in html.table_headers - a layout wrapper, generated markup, or a role settled at render time.

One shape is worth naming as a limitation: a table whose rows are generated from a single source row (<tr th:each="m : ${team}">, v-for, *ngFor) is not judged. The file shows one row of a table that will render with many, so its shape here is not the shape a user meets - the same reason generated markup is left alone throughout this family. Such a table’s headers are not checked by this rule, by html.table_headers or by html.table_cell_headers.

Remediation

Add the scope the header covers:

<table>
  <caption>The team</caption>
  <tr><th scope="col">Name</th><th scope="col">Role</th></tr>
  <tr><th scope="row">Ada</th><td>Owner</td></tr>
</table>

Use scope="col" for a header above its column, scope="row" for one at the start of its row, and colgroup / rowgroup for a header spanning a group. When the layout is more involved than that, give each header an id and let the data cells name them in headers - see html.table_cell_headers.

For a <td scope="…​">, decide what the cell is. If it heads its row or column, make it a <th> and keep the scope - the row above is what that looks like; if it holds data, drop the attribute, which has no effect there either way.

© 2026 Xygeni. All rights reserved