Accessible Group Header Rows in Data Tables
Permalink to "Accessible Group Header Rows in Data Tables"A group header row is a full-width row that labels the rows beneath it — “Region: North”, “Status: Overdue”, “March 2026”. Sighted users see the grouping instantly from typography and spacing. Screen reader users only get it if the header is programmatically associated with the rows it labels; otherwise the group label is read once, as a stray one-cell row, and every data cell below it is announced without it.
This page shows the native HTML markup that creates that association, and when an interactive grid should express grouping differently. It belongs to row grouping & totals in data tables.
Spec reference
Permalink to "Spec reference"HTML provides the structure directly. A table can contain several <tbody> elements; each is a row group. A <th scope="rowgroup"> is a header for all cells in its row group, in addition to the row and column headers those cells already have. The HTML table model’s header-assignment algorithm uses scope to decide which headers apply to each cell, and browsers expose the result to screen readers through the accessibility tree.
colspan on the group header lets it span all columns visually. That is fine: a spanning cell with scope="rowgroup" still applies to every data cell in its <tbody>.
For role="grid" or role="treegrid" built from <div>s, there is no rowgroup scope. Grouping is expressed with role="rowgroup" containers and — more usefully — with a treegrid hierarchy where the group row has aria-level="1" and its members aria-level="2".
Criteria in play: SC 1.3.1 Info and Relationships (the group is a relationship and must be programmatic), SC 1.3.2 Meaningful Sequence, and SC 2.4.6 Headings and Labels for the quality of the group label.
When to use rowgroup headers — and when not to
Permalink to "When to use rowgroup headers — and when not to"Use them for static tables grouped by one dimension: reports, statements, grouped search results. They cost nothing beyond correct markup and work in browse mode, where most table reading happens.
Use a treegrid instead when groups can be collapsed and the grid is interactive, or when grouping is nested more than one level. scope="rowgroup" has no notion of depth — a second level of grouping inside a <tbody> is not expressible — and a collapse control needs the treegrid’s expanded state; see collapsing row groups with aria-expanded.
The misapplication to name is styling a normal <td colspan> row as a group header. It looks identical and relates to nothing: screen readers read “North” as a one-cell row, and the next row’s cells are announced with only their column headers.
Annotated code example
Permalink to "Annotated code example"<table>
<caption>Open invoices by region</caption>
<thead>
<tr>
<th scope="col">Invoice</th>
<th scope="col">Customer</th>
<th scope="col">Due</th>
<th scope="col">Amount (EUR)</th>
</tr>
</thead>
<!-- SC 1.3.1: one tbody per group = one row group -->
<tbody>
<tr class="group-header">
<!-- rowgroup scope: header for every cell in this tbody -->
<th scope="rowgroup" colspan="4">North <span class="count">(2 invoices)</span></th>
</tr>
<tr>
<th scope="row">INV-1042</th> <!-- per-row header still applies -->
<td>Northwind</td><td>2026-03-04</td><td>1,280.00</td>
</tr>
<tr>
<th scope="row">INV-1051</th>
<td>Litware</td><td>2026-03-09</td><td>330.00</td>
</tr>
</tbody>
<tbody>
<tr class="group-header">
<th scope="rowgroup" colspan="4">South <span class="count">(1 invoice)</span></th>
</tr>
<tr>
<th scope="row">INV-1047</th>
<td>Contoso</td><td>2026-03-06</td><td>90.00</td>
</tr>
</tbody>
</table>
/* Visual grouping that does not depend on colour alone (SC 1.4.1) */
tbody + tbody { border-top: 2px solid var(--color-rule-strong); }
.group-header th { text-align: start; font-weight: 700; padding-top: 1rem; }
Including the count in the group header (“North (2 invoices)”) helps everyone. Screen reader users hear the size of the group before reading into it, which lets them decide whether to skip it — a small version of the orientation a sighted user gets by glancing at the page.
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Event | NVDA + Firefox | JAWS + Chrome | VoiceOver + Safari |
|---|---|---|---|
Ctrl+Alt+Down into a group’s first row |
“North, INV-1042, row 3” | “North, row 3, INV-1042” | Group not announced on row change |
| Move across cells in a group | Column header + cell | Column header + cell | Column header + cell |
| Move down into the next group | “South” announced as the group changes | “South” announced | Reads the group header row as a row |
Insert+Numpad 5 / JAWS “say cell headers” |
Includes the rowgroup header | Includes it | — |
VoiceOver’s weaker support is the reason to keep the group header row itself readable as ordinary content: when VoiceOver users arrow into it, they still hear “North (2 invoices)”.
Integration context
Permalink to "Integration context"Groups usually end with a subtotal. The subtotal row belongs inside the group’s <tbody> so it inherits the group header, and it needs its own row header (“North subtotal”); the details are in announcing totals and summary rows.
If your groups are generated client-side from a flat data set — “group by status” in a table toolbar — regenerate the <tbody> structure rather than inserting header rows into a single body. Frameworks make it tempting to render one <tbody> and interleave header rows; the scope then leaks across groups.
Gotchas
Permalink to "Gotchas"One <tbody> with several group rows. scope="rowgroup" applies to the whole row group, so two group headers in one <tbody> both apply to every row in it. One group per <tbody>, always.
Sorting inside groups. Sort within each group, not across them, or the grouping becomes meaningless. Announce “sorted within groups” so users know the order is local.
Sticky group headers. Making group headers sticky is fine visually; keep them in the DOM in their natural position. Cloning a header into a fixed overlay creates a duplicate that is read twice.
Testing checklist
Permalink to "Testing checklist"FAQ
Permalink to "FAQ"How do I make a group header row apply to the rows below it?
Put each group in its own tbody and make the group label a th with scope=“rowgroup”. The HTML header algorithm then applies it to every cell in that tbody, alongside the cells’ row and column headers.
Can grouped rows be nested more than one level with scope="rowgroup"?
No. A row group has no depth, and a tbody cannot contain another tbody. For nested grouping, use a treegrid with aria-level on each row, or split the data into separate tables.
Should the group header row be a heading element?
Not inside the table. Headings inside table cells break table navigation for some readers and duplicate the structure. Use th scope=“rowgroup”; if groups are very large, consider separate tables each with its own heading and caption instead.
Does VoiceOver announce rowgroup headers?
Not reliably when moving between cells. It does read the group header row when the user arrows into it, which is why the header text should be informative on its own — including the group name and row count.
Related
Permalink to "Related"- Collapsing row groups — making the groups expandable
- Announcing totals & summary rows — subtotals at the end of each group
- scope and headers in complex tables — the scope values in depth