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.

Two row groups in one table Mock table of invoices grouped by region, with a spanning group header row for North and one for South, each above its own rows. Two row groups in one tableInvoiceCustomerDueAmountNorth1INV-10422NorthwindMar 41,280INV-1051LitwareMar 9330South1INV-1047ContosoMar 6901th scope="rowgroup" colspan="4" —applies to every cell in its tbody2th scope="row" — the per-row header stillapplies
Each group is its own tbody, and each group header is a th with scope="rowgroup".

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)”.

Rowgroup header support by reader Matrix showing whether NVDA, JAWS, VoiceOver and TalkBack announce a rowgroup header when entering a group and when asked for cell headers. Rowgroup header support by readerScreen readerOn entering groupCell headers commandFallback heardNVDA + FirefoxAnnouncedIncludedGroup row readJAWS + ChromeAnnouncedIncludedGroup row readVoiceOver + SafariNot announcedNo commandGroup row readTalkBack + ChromeNot announcedNo commandGroup row read
Support is strong on Windows and weaker elsewhere — the readable group row itself is the fallback.

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.

A styled group row and a real one Comparison of a group header made from a styled full-width td against one made from a th with scope="rowgroup" in its own tbody. A styled group row and a real one✗ Styled td colspanRead once as a one-cell rowCells below announce only column headersNo group in "say cell headers"Sorting may scatter it among the data✓ th scope="rowgroup"Its own tbody holds the groupApplies to every cell in the groupNVDA and JAWS announce group changesCount in the text orients every reader
They look identical; only one of them tells a screen reader which rows belong to North.

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.

Permalink to "Related"

← Back to Row Grouping & Totals in Data Tables