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"
Anatomy of a grouped table Stacked layers of a grouped table: caption and summary, thead with column headers, one tbody per group with a rowgroup header and subtotal, and tfoot with the grand total. Anatomy of a grouped tablecaption + summarynames the data set and explains the grouping and where totals aretheadcolumn headers with units; scope="col" or ids for multi-level headerstbody per groupth scope="rowgroup" header with count, data rows, then a named subtotal rowtfootth scope="row" "Total, 412 invoices" and the grand total cells
Every visual element of a grouped report has a structural counterpart — nothing is conveyed by typography alone.

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.`);
From flat rows to an accessible grouped report Flow of the five implementation steps: group into tbody elements, add rowgroup headers, add named summary rows, add collapse controls, and announce totals. From flat rows to an accessible grouped reportGroup rowsone tbody pergroupLabel groupsthscope="rowgroup"with countName totalssubtotal rows andtfootCollapsebutton witharia-expandedAnnouncechanged totals,once
Steps one to three are pure markup; only four and five need script.

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
What conveys each grouping concept Matrix mapping group membership, group size, summary meaning and collapsed state to the markup that conveys each, with the common purely visual substitute. What conveys each grouping conceptConceptStructure that conveys itVisual-only habitGroup membershiptbody + th scope="rowgroup"bold full-width tdGroup sizecount in the header textbadge colourSummary meaningth scope="row" "Total"bold number, empty first cellCollapsed statearia-expanded on a buttonrotated chevron only
Each concept on the left has a visual habit that looks right and a structural element that is right.

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.

Rows read to reach one region's subtotal Bar chart comparing how many rows a screen reader user reads to reach the fifth region's subtotal in a 412-row report: flat reading, with group headers and skipping, and with all groups collapsed. Rows read to reach one region's subtotalFlat reading, no structure180 rows — every row until the fifth group endsGroup headers, skip by group45 rowsAll groups collapsed5 rows
Structure lets users skip; collapse lets them skip almost everything.

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.

Permalink to "Related"

← Back to Accessible Data Tables & Grid Systems