Grouping by Multiple Columns
Permalink to "Grouping by Multiple Columns"Grouping by one column is a header row and some children. Grouping by two or three columns turns a flat table into a tree: Region › Status › Owner, with subtotals at every level. Sighted users read the indentation at a glance. Screen reader users need that structure spelled out — which level they are on, which parent groups they are inside, how many rows each group holds — and a way to change the grouping without a drag-and-drop “group by” bar.
This guide belongs to row grouping and aggregation.
Spec reference
Permalink to "Spec reference"- ARIA treegrid —
aria-level,aria-setsize,aria-posinsetandaria-expandedon group rows convey nesting. - SC 1.3.1 Info and Relationships (A) — group membership and level must be programmatic, not just indentation.
- SC 2.5.7 Dragging Movements (AA) — drag-to-group bars need a non-drag alternative.
- SC 4.1.3 Status Messages (AA) — regrouping announced without moving focus.
When to use — and when not to
Permalink to "When to use — and when not to"Use multi-column grouping when users analyse data along two or three dimensions and need subtotals at each. Beyond three levels, screen reader users lose track of the path; consider a pivot table or a filter instead.
Do not group by high-cardinality columns such as invoice number or date-time; each group would hold one row. The misapplication: indentation alone for nesting, with flat role="row" elements that all report level 1.
Annotated code example
Permalink to "Annotated code example"<div class="group-by">
<label for="g1">Group by</label>
<select id="g1"><option>Region</option>…</select>
<label for="g2">Then by</label>
<select id="g2"><option>Status</option>…</select>
<label for="g3">Then by</label>
<select id="g3"><option value="">None</option><option>Owner</option>…</select>
</div>
<div role="treegrid" aria-label="Invoices grouped by region, status" aria-rowcount="412">
<div role="row" aria-level="1" aria-expanded="true" aria-posinset="1" aria-setsize="2">
<div role="gridcell" tabindex="0">Region: North — 120 invoices, 48,200.00</div>
</div>
<div role="row" aria-level="2" aria-expanded="false" aria-posinset="1" aria-setsize="2">
<div role="gridcell" tabindex="-1">North › Status: Overdue — 14 invoices, 6,410.00</div> <!-- path in the label -->
</div>
</div>
<p role="status" class="sr-only" id="group-status"></p>
function onGroupingChange(cols) {
regroup(cols);
status.textContent = `Grouped by ${cols.join(', then ')}. ${topGroups.length} top-level groups.`; // SC 4.1.3
}
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Key | On group row | Announcement |
|---|---|---|
| Right Arrow | Expand group | “expanded, 14 rows” |
| Left Arrow | Collapse, or move to parent | “collapsed” / parent name |
| Down Arrow | Next visible row | Row content, “level 3” |
| Home / End | First / last row | Row content |
| Group-by select | Change grouping | “Grouped by Region, then Status” |
Step-by-step implementation
Permalink to "Step-by-step implementation"- Offer select controls for grouping order. Three labelled selects (“Group by”, “Then by”, “Then by”) are simpler than a drag bar and fully keyboard accessible. If you keep a drag bar, add the selects as its alternative.
- Compute group rows with levels. Each group header row gets
aria-levelequal to its depth,aria-posinsetandaria-setsizeamong siblings, andaria-expanded. Data rows get the next level down. - Put the path and counts in the header label. “North › Overdue — 14 invoices, 6,410.00” tells a user exactly where they are without walking back up the tree.
- Keep aggregates in cells. If subtotals appear in numeric columns, put them in gridcells under the right column so column headers are announced with them.
- Announce regrouping. After a change, announce the new grouping and number of top-level groups, and keep focus on the control that changed.
Integration context
Permalink to "Integration context"Single-level group headers are covered in accessible group header rows, collapse behaviour in collapsing row groups with aria-expanded and subtotals in announcing totals and summary rows. For cross-tabulation, see accessible pivot tables. Treegrid navigation is shared with tree views and hierarchical navigation.
Gotchas
Permalink to "Gotchas"aria-level with native tables. aria-level is only supported on rows in a treegrid; in a plain table, put the level in the group header text (“Level 2: Overdue”).
Virtualization. With virtualized rows, aria-posinset and aria-setsize must be computed from data, not from rendered nodes.
Sorting inside groups. Sorting reorders rows within each group; say so in the sort announcement: “Sorted by amount within groups”.
Edge cases
Permalink to "Edge cases"- Empty groups after filtering. Remove them rather than showing “0 rows” headers that waste navigation.
- Same value at different levels. If “Owner: M. Ortiz” appears under several regions, the path in the label distinguishes them.
- Collapsed by default. Large data sets often start collapsed; say how many groups exist so users know where to begin.
Worked example
Permalink to "Worked example"A finance team grouped invoices by region, then status, then owner in a commercial grid with a drag-to-group bar. Screen reader users heard a flat list of rows with no levels and could not change the grouping. The team added three grouping selects, switched the grid to treegrid mode with levels and set sizes, put the full path and subtotal in each group header, and announced regrouping. A blind analyst could now answer “how much is overdue in the North for each owner” in under a minute.
Common mistakes
Permalink to "Common mistakes"- Indentation without levels.
- Drag-only grouping.
- Group headers without counts or paths.
- Moving focus into the grid after regrouping.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Expose grouping as a data-grid prop with an accessible group-by control included, and generate path labels from the grouping definition so teams cannot forget them.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"With NVDA and Firefox, tab to the “Group by” selects and change the second level. Listen for the regrouping message and confirm that focus stays on the select. Move into the treegrid and arrow down through group rows: NVDA should report “level 1”, “level 2” and the expanded state, and each header should read its path and count. Press Left Arrow on a level 3 row to move to its parent and confirm the parent is announced. In JAWS, repeat with table navigation keys. In VoiceOver, where levels are announced less reliably, confirm that the path in each header label still gives full context.
FAQ
Permalink to "FAQ"How many grouping levels are reasonable?
Two or three. Beyond that, use filters or a pivot table.
Should group headers be focusable?
Yes, in a treegrid; they carry expand state and summaries.
Can I keep a drag-to-group bar?
Yes, alongside selects or a menu that achieves the same result.
Where do subtotals go?
In group header rows or summary rows, in cells aligned under their columns.
How do screen readers announce level changes?
NVDA and JAWS announce “level 2” when moving between levels in a treegrid; VoiceOver support varies, so the path in the label is the fallback.
Does grouping need a live region?
Only for the regrouping action itself; expand and collapse are conveyed by aria-expanded.
Related
Permalink to "Related"- Accessible group header rows — the single-level pattern
- Sticky group headers while scrolling — keeping context visible
- Accessible pivot tables — cross-tabulation