Row Grouping & Totals in Data Tables
Permalink to "Row Grouping & Totals in Data Tables"Grouped tables are how data applications summarise: invoices by region, tickets by assignee, spend by cost centre and quarter. They add three things to a plain table — group headers, summary rows, and often the ability to collapse groups — and each is usually built with styling alone. A bold full-width row reads as a group header to a sighted user and as a stray one-cell row to a screen reader. A bold row at the bottom reads as a total to one and as another invoice to the other.
This topic covers the markup and behaviour that make grouping and aggregation programmatic: <tbody> row groups with scope="rowgroup" headers, named summary rows and <tfoot>, collapsible groups, and the header wiring that keeps pivot tables readable. It is written for frontend engineers building report and dashboard tables, and for design system maintainers whose table component will be asked to “support grouping” sooner or later.
It is part of accessible data tables & grid systems and assumes the basics in semantic HTML table construction.
WCAG criteria in scope
Permalink to "WCAG criteria in scope"| Criterion | Level | Relevance to grouping and totals |
|---|---|---|
| 1.3.1 Info and Relationships | A | Group membership and the meaning of summary rows must be programmatic, not visual |
| 1.3.2 Meaningful Sequence | A | Subtotals follow their group and the grand total follows the bodies in reading order |
| 1.4.1 Use of Color | A | Group separation and summary emphasis cannot rely on colour alone |
| 2.4.6 Headings and Labels | AA | Group and total labels describe their content: “North, 38 invoices”; “Total, 412 invoices” |
| 4.1.2 Name, Role, Value | A | Collapse controls expose aria-expanded; treegrid rows expose level and position |
| 4.1.3 Status Messages | AA | Recalculated totals and expand-all results are announced without moving focus |
Prerequisites
Permalink to "Prerequisites"- Column and row headers with
scope, and theid/headerstechnique for complex headers — covered in correct usage of scope and headers in complex tables. - A caption that names the table and, for complex structures, a summary that explains it — see writing table captions and summaries.
- For collapsible or nested groups in interactive grids, the treegrid model in building an accessible treegrid with expandable rows.
- A shared status region for the table, as in ARIA live regions for dynamic data.
ARIA & HTML spec reference
Permalink to "ARIA & HTML spec reference"| Element / attribute | Valid values | When to apply | Common misuse |
|---|---|---|---|
<tbody> |
— (repeatable) | One per group | One body for all groups, with header rows interleaved |
<th scope="rowgroup"> |
rowgroup |
The group label, usually with colspan |
A styled <td> that relates to nothing |
<th scope="row"> in a summary row |
row |
Names the aggregate: “North subtotal”, “Total” | Empty first cell with a bold number |
<tfoot> |
— | Grand totals and whole-table summaries | Grand total inside <tbody>, sorted with the data |
aria-expanded on a <button> |
true, false |
Collapsible groups in static tables | On the <tr>, where it is unsupported |
aria-level, aria-expanded on row |
integer; true/false |
Treegrid grouping, especially nested | Mixed with scope="rowgroup" in the same table |
headers |
space-separated ids | Pivot cells with two or more header levels per axis | Only the innermost header listed |
Step-by-step implementation
Permalink to "Step-by-step implementation"Step 1 — One tbody and one rowgroup header per group (SC 1.3.1)
Permalink to "Step 1 — One tbody and one rowgroup header per group (SC 1.3.1)"<tbody>
<tr><th scope="rowgroup" colspan="4">North <span class="count">(38 invoices)</span></th></tr>
<!-- data rows -->
</tbody>
Step 2 — Name every summary row (SC 1.3.1, 2.4.6)
Permalink to "Step 2 — Name every summary row (SC 1.3.1, 2.4.6)"<tr class="subtotal"><th scope="row" colspan="3">North subtotal</th><td>48,210.00</td></tr>
<!-- … after all bodies … -->
<tfoot><tr><th scope="row" colspan="3">Total, 412 invoices</th><td>501,930.00</td></tr></tfoot>
Step 3 — Make groups collapsible without losing their summary (SC 4.1.2)
Permalink to "Step 3 — Make groups collapsible without losing their summary (SC 4.1.2)"<th scope="rowgroup" colspan="4">
<button type="button" aria-expanded="true" aria-controls="grp-north">
North — 38 invoices, subtotal 48,210.00
</button>
</th>
Step 4 — Wire pivot headers explicitly (SC 1.3.1)
Permalink to "Step 4 — Wire pivot headers explicitly (SC 1.3.1)"<td headers="y2026 q2 north widgets-n">1,240</td>
Step 5 — Announce totals that change (SC 4.1.3)
Permalink to "Step 5 — Announce totals that change (SC 4.1.3)"if (prevTotal !== total) status(`${shown} invoices shown. Total ${fmt(total)} euros.`);
Keyboard interaction contract
Permalink to "Keyboard interaction contract"| Key | Context | Action | Expected AT announcement | Failure indicator |
|---|---|---|---|---|
Ctrl+Alt+Down |
Static table | Move into a new group | “South, INV-1047, …” (group announced on change) | Group never announced |
Tab |
Collapsible group header | Focus the toggle | “North — 38 invoices, subtotal 48,210.00, button, expanded” | Toggle unreachable or unnamed |
Enter / Space |
Toggle | Collapse or expand | “collapsed” / “expanded” | State not announced; rows still read |
Right / Left Arrow |
Treegrid group row | Expand / collapse | “expanded, level 1” | Arrow moves cells instead |
| (edit or filter) | Data changes | Totals recalculate | “Total now 501,930.00 euros.” | Silent change, or message when unchanged |
Screen reader compatibility matrix
Permalink to "Screen reader compatibility matrix"| AT + browser | rowgroup header on group change | Summary row naming | Collapse state |
|---|---|---|---|
| NVDA + Firefox | Announced | “Total, Amount, …” | Button state announced |
| NVDA + Chrome | Announced | Announced | Announced |
| JAWS + Chrome | Announced; “table footer” on tfoot |
Announced | Announced |
| VoiceOver + Safari | Not announced on cell moves; group row read as content | Announced | Announced after a pause |
| TalkBack + Chrome | Not announced; group row read | Announced | Announced |
Edge cases & failure modes
Permalink to "Edge cases & failure modes"1. Two group headers in one tbody
Permalink to "1. Two group headers in one tbody"Diagnosis: both scope="rowgroup" headers apply to every row in the body, so each cell is announced with two groups. Fix: one group per <tbody>; rebuild the bodies when regrouping client-side.
2. Sorting moves the total
Permalink to "2. Sorting moves the total"Diagnosis: the grand total lives in <tbody> and a descending sort puts it first. Fix: move it to <tfoot> and exclude summary rows from the sort model.
3. Page totals presented as totals
Permalink to "3. Page totals presented as totals"Diagnosis: a paginated table shows “Total” for the visible page only. Fix: label it “Page total” and show the data-set total separately.
4. Collapsed by default with uninformative headers
Permalink to "4. Collapsed by default with uninformative headers"Diagnosis: a report loads with every group collapsed and headers that say only “North”. It sounds like a table of twelve rows. Fix: put count and subtotal in the header, or load expanded.
5. Pivot corner cell miscounted
Permalink to "5. Pivot corner cell miscounted"Diagnosis: a missing corner cell shifts every column header, so every value is announced under the wrong quarter. Fix: generate the corner from the number of row-header columns, and test one cell’s headers with “say all cell headers”.
Choosing a grouping strategy
Permalink to "Choosing a grouping strategy"Grouping is a presentation decision with structural consequences, and the right structure depends on how the table is used rather than how it looks.
One level, read-only. A statement grouped by month, a report grouped by region. Use <tbody> per group with a rowgroup header and a subtotal row. Nothing is interactive except, optionally, a collapse button per group. This covers most grouped tables in business applications, and it works in browse mode, which is how most screen reader users read tables.
One level, interactive. The same grouping in a grid where users edit cells or select rows. You can keep <tbody> grouping inside a role="grid" built on a native table, but collapse and selection interact: selecting a collapsed group should say what it selects. Many teams find a treegrid simpler at this point, because group rows then become first-class focusable rows.
Nested. Region, then country, then city. HTML row groups cannot nest, so this is a treegrid with aria-level. Each level’s rows carry aria-posinset and aria-setsize among their siblings, and the group rows carry aria-expanded.
Two axes. Anything with grouped columns as well as grouped rows is a pivot. The headers attribute is the only dependable wiring, and depth on either axis past two is a strong signal to offer a flat alternative.
When users can change the grouping — a “Group by” menu in the toolbar — announce the new structure: “Grouped by status, 4 groups.” Regrouping replaces the entire table body, so focus should move to the caption, exactly as it would after a page change.
Cross-cutting concerns
Permalink to "Cross-cutting concerns"Sorting and grouping together. Sort within groups, keep groups in a stable order (or sort groups by their subtotal if that is what the control says), and never let summary rows join the sort. Announce the combined result: “Sorted by amount within each region.”
Filtering and empty groups. A filter can leave a group with no rows. Either remove the group header or keep it with “0 invoices” — but be consistent, and let the result count mention how many groups remain.
Selection across groups. A group-level checkbox that selects all members is a select-all in miniature: it needs an indeterminate state, a name that includes the group (“Select all 38 invoices in North”), and a count announcement.
Virtualisation. A virtualised grouped grid renders group headers only when they scroll into view. Keep group rows in the row count and position metadata so “group 5 of 12” stays true, and consider a group navigator — a list of group links — for long reports.
Printing and export. Grouped structure should survive print and export. A CSV that flattens groups should add the group as a column, so users who move the data elsewhere keep the relationship.
Group header rows
Permalink to "Group header rows"The foundation of the topic. Accessible group header rows shows the <tbody>-per-group markup, why the count belongs in the header text, and how VoiceOver’s weaker support is covered by keeping the header row readable as content.
<th scope="rowgroup" colspan="4">North (2 invoices)</th>
Behaviour note: NVDA and JAWS announce the group as the user crosses into it; VoiceOver users hear it when they read the header row itself.
Totals and summary rows
Permalink to "Totals and summary rows"Announcing totals and summary rows covers where subtotals and grand totals belong, how a row header turns a bold number into a named aggregate, and when to announce a recalculated total.
<tfoot><tr><th scope="row" colspan="2">Total, 3 invoices</th><td>1,700.00</td></tr></tfoot>
Behaviour note: combine a total change with the filter count into one message rather than racing two.
Collapsible row groups
Permalink to "Collapsible row groups"Collapsing row groups with aria-expanded compares the static-table approach — a disclosure button inside the rowgroup header — with treegrid group rows, and covers expand-all, collapse-all and hidden rows.
rows.forEach((tr) => { tr.hidden = collapsed; }); // leaves the tree too
Behaviour note: the button’s state change is the announcement; only expand-all and collapse-all need a status message.
Pivot tables
Permalink to "Pivot tables"Accessible pivot tables and cross-tabulations handles two-axis, multi-level headers with explicit headers lists, named margins, and the point at which a flat long-format alternative serves users better.
<td headers="y2026 q2 north widgets-n">1,240</td>
Behaviour note: Windows readers announce only the headers that change as users move, which keeps two-level pivots comfortable to read.
Design system integration
Permalink to "Design system integration"A table component that supports grouping should generate the structure from data rather than accept arbitrary “header rows” from consumers. A groupBy option that produces <tbody> elements with rowgroup headers, a summary option that produces named subtotal and total rows, and a collapsible flag that renders the disclosure button are enough to make every grouped table in the product accessible by default.
| Component variant | Structure it must emit | Criterion |
|---|---|---|
| Grouped | <tbody> per group, <th scope="rowgroup"> with count |
1.3.1 |
| Grouped + totals | Subtotal row per group; <tfoot> total; both named |
1.3.1, 2.4.6 |
| Collapsible | Button with aria-expanded; hidden member rows |
4.1.2 |
| Pivot | id on every header; headers on every cell |
1.3.1 |
Tokens: group separators need a rule colour with 3:1 contrast against the table background (SC 1.4.11) so separation is not carried by a faint tint alone.
Testing checklist
Permalink to "Testing checklist"Automated
Permalink to "Automated"Keyboard
Permalink to "Keyboard"Screen reader
Permalink to "Screen reader"FAQ
Permalink to "FAQ"How do I group rows in an accessible HTML table?
Give each group its own tbody and label it with a th scope=“rowgroup” in its first row. Every cell in that body is then associated with the group label as well as its own row and column headers.
How should totals be marked up?
As rows with a th scope=“row” that names the aggregate — “North subtotal”, “Total, 412 invoices”. Subtotals are the last row of their group’s tbody; the grand total goes in tfoot after the bodies.
Should changing the grouping be announced?
Yes. Regrouping replaces the table body, so move focus to the caption and announce the new structure — for example “Grouped by status, 4 groups, 412 invoices” — in the same way you would after a page change. Without it, screen reader users are left inside a table whose structure changed around them.
How should a grouped table be exported?
Keep the grouping as data. A flattened CSV should include the group as its own column, and subtotal rows should either be omitted or marked in a type column, so that users who continue the work in a spreadsheet keep the relationships the table expressed.
When should a grouped table become a treegrid?
When groups are nested more than one level, or when the grid is interactive and users collapse and expand groups with arrow keys. For a static report with one level of grouping, rowgroup headers and a disclosure button are simpler and more robust.
Related
Permalink to "Related"- Semantic HTML table construction — the header mechanics underneath
- Expandable rows & nested data — the treegrid model for hierarchy
- Sortable & filterable data grids — sorting within groups
- Responsive data tables — grouped tables on small screens
- Chart alternatives — pivots as the table behind a chart