DEV Community

Cover image for th scope row vs col: table headers that sound fine but are wrong (WCAG 1.3.1)
A11Y Practice
A11Y Practice

Posted on Originally published at wasexamprep.com

th scope row vs col: table headers that sound fine but are wrong (WCAG 1.3.1)

"Jane Doe" on its own is a word in a box. Is she the instructor, a student, a guest? In a properly built table, VoiceOver says Instructor Jane Doe, column 2 of 3 - the header is what turns the word into a fact. Lose that relationship in the code and the table is just a grid of words, no matter how clear it looks on screen.

This breakdown uses the Table Headers lab (E09): Case 1 (<th> without scope) and Case 2 (scope="row" on column headers). Both use real header cells and both sound fine in VoiceOver. The evidence comes from real accessibility-tree and VoiceOver captures.

The problem

Case 1 - <th> without scope

The course table has a proper thead with three <th> cells, but none of them says which direction it labels:

<table id="table-headers-broken-case1-course-table">
  <thead>
    <tr>
      <th id="table-headers-broken-case1-course-header">Course</th>
      <th>Instructor</th>
      <th>Room</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>Intro to WAS</td>
      <td>Jane Doe</td>
      <td>101</td>
    </tr>
    <!-- ARIA Patterns / Sam Lee / 204, Keyboard Testing / Alex Kim / 115 -->
  </tbody>
</table>
Enter fullscreen mode Exit fullscreen mode

What the accessibility tree showed (broken)

  • table "" > rowgroup "" > row "" with columnheader "Course", columnheader "Instructor" and columnheader "Room"
  • Focused columnheader "Course": Role columnheader, Focusable: false - and no Scope property in the Chrome pane, because there is no scope in the HTML
  • Data rows are row "" nodes with cell children (cell "Intro to WAS", cell "Jane Doe", cell "101")

Chrome inferred the column headers from position in thead.

What VoiceOver announced (broken)

Moving through the table with Control-Option and the arrow keys:

table, 3 columns, 4 rows

Course Course, column 1 of 3

row 2 of 4 Course Intro to WAS, column 1 of 3

Instructor Jane Doe, column 2 of 3

end of table

Inferred, not declared

The guess landed, but it was a guess. The W3C WAI Tables Tutorial accepts top-row <th> without scope only for small tables with clearly distinct columns; scope is needed when data is ambiguous or the table is larger. The WAS rule is simpler: put scope on every <th> whenever you can.

Case 2 - scope="row" on column headers

This is the partial fix. Someone read "add scope" and added it - with the wrong value:

<thead>
  <tr>
    <th scope="row" id="table-headers-broken-case2-course-header">Course</th>
    <th scope="row">Instructor</th>
    <th scope="row">Room</th>
  </tr>
</thead>
Enter fullscreen mode Exit fullscreen mode

What the accessibility tree showed (broken, Case 2)

  • The header row now exposes rowheader "Course", rowheader "Instructor" and rowheader "Room" - not columnheader
  • Focused rowheader "Course": Role rowheader. Chrome's pane shows no Scope property, so the scope="row" itself is read from the markup.
  • The data row / cell structure is unchanged

The code now claims each of those cells labels its own row, not the column below it. The page shows one relationship; the code declares another.

What VoiceOver announced (broken, Case 2)

The lab's capture notes record VoiceOver for Case 2 as identical to Case 1, so it was not re-recorded. By ear, nothing changed. The failure only shows up in the tree.

Why this fails exam-style

  • 1.3.1 Info and Relationships (Level A) requires that relationships conveyed visually can be programmatically determined.
  • Case 2 declares the wrong relationship: the visual table has column headers, the code has row headers.
  • Case 1 relies on browser inference. It happens to work here, but the association is inferred, not declared - which is why the WAS habit is explicit scope.
  • Exam signal for a wrong scope: rowheader in the tree, confirmed by scope="row" in the markup. Exam signal for missing versus correct scope on a simple thead table: the HTML, not Chrome's pane.
  • <caption> and headers / id are separate topics (Table Captions and Complex Relationships labs).

The fix

Give every column header scope="col". One attribute, on each <th> in the header row. The same fix repairs both cases:

<thead>
  <tr>
    <th scope="col" id="table-headers-fixed-case1-course-header">Course</th>
    <th scope="col">Instructor</th>
    <th scope="col">Room</th>
  </tr>
</thead>
Enter fullscreen mode Exit fullscreen mode

In React / JSX the attribute keeps its name. When headers run down the side instead, scope="row" belongs on the first cell of each data row (illustrative, not part of this lab):

<tr>
  <th scope="row">Intro to WAS</th>
  <td>Jane Doe</td>
  <td>101</td>
</tr>
Enter fullscreen mode Exit fullscreen mode

What changed in the tree (fixed)

  • Both fixed cases expose columnheader "Course", columnheader "Instructor" and columnheader "Room"
  • Case 2: the three rowheader nodes from the broken version are columnheader again; data rows unchanged
  • Case 1: the tree is identical to broken Case 1. Chrome shows no Scope property for scope="col" (live capture 2026-06-15). For missing versus correct scope, read the markup.

What VoiceOver announced (fixed)

Case 1:

table, 3 columns, 4 rows

Course, column 1 of 3

Contrast broken Case 1, where the same header read Course Course, column 1 of 3.

Course Intro to WAS, column 1 of 3

You are currently on a selectable text, inside of a cell.

To be honest about the evidence: Instructor Instructor, column 2 of 3 and Room Room, column 3 of 3 still double on the fixed table, so the doubling is not a reliable pass or fail signal.

Case 2:

table, 3 columns, 4 rows

You are currently on a selectable text, inside of a column header.

Behaviors to verify

  1. Every header cell is a <th>, not a styled <td>, and its text describes the data it labels.
  2. Every column header has scope="col"; every row header has scope="row"; no scope="row" sits on column labels in thead.
  3. In the accessibility tree, column labels are columnheader and row labels are rowheader. Then move through a data row with a screen reader and confirm each cell is read with the right header. Do not trust your ears alone.

Practice it

Takeaway: a table without declared headers is just a grid of words. Put scope on every th, and check the tree, not just your ears.

If this was useful, subscribe to A11Y in Practice on YouTube (@a11yinpractice) for more hands-on accessibility episodes, and visit wasexamprep.com for WAS study notes and labs.

A11Y in Practice and wasexamprep.com are independent educational resources. They are not affiliated with or endorsed by IAAP (International Association of Accessibility Professionals).

Top comments (0)