Multi-Level Column Headers with colspan
Permalink to "Multi-Level Column Headers with colspan"Financial and statistical tables often have two header rows: years over quarters, regions over metrics, plan over actual. Sighted users see that “Q1” sits under “2025”. Screen reader users only get that relationship if the markup states it. With colspan, scope="colgroup" and <colgroup> elements, most screen readers announce both header levels for each data cell. With irregular spans, you need explicit headers attributes.
This guide belongs to semantic HTML table construction.
Spec reference
Permalink to "Spec reference"- HTML tables —
<colgroup span>,colspan,scope="colgroup"andscope="col"define the header model. - SC 1.3.1 Info and Relationships (A) — header associations must be programmatic.
- WAI Tables tutorial: multi-level headers — recommends headers/id for irregular structures.
- SC 1.3.2 Meaningful Sequence (A) — header rows read in a logical order.
When to use — and when not to
Permalink to "When to use — and when not to"Use multi-level headers when the data has a genuine two-dimensional column structure and readers compare across both levels. Keep to two levels where possible.
Do not use spanning headers as decorative group titles above unrelated columns; a caption or visible heading is clearer. The misapplication: a top header row of styled td cells with colspan and no scope, so screen readers ignore the top level entirely.
Annotated code example
Permalink to "Annotated code example"<table>
<caption>Revenue by quarter, 2024–2025 (thousands)</caption>
<colgroup span="1"></colgroup> <!-- row header column -->
<colgroup span="4"></colgroup> <!-- 2024 -->
<colgroup span="4"></colgroup> <!-- 2025 -->
<thead>
<tr>
<td rowspan="2"></td>
<th scope="colgroup" colspan="4">2024</th> <!-- spans its colgroup -->
<th scope="colgroup" colspan="4">2025</th>
</tr>
<tr>
<th scope="col">Q1</th><th scope="col">Q2</th><th scope="col">Q3</th><th scope="col">Q4</th>
<th scope="col">Q1</th><th scope="col">Q2</th><th scope="col">Q3</th><th scope="col">Q4</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">North</th><td>1,120</td>…</tr>
</tbody>
</table>
<!-- Irregular: explicit headers -->
<th id="y25" colspan="3">2025</th> … <th id="y25-q1">Q1</th>
<td headers="y25 y25-q1 north">1,240</td>
Keyboard & AT behaviour
Permalink to "Keyboard & AT behaviour"| Screen reader | Moving to a 2025 Q1 cell (Ctrl+Alt+Arrow) |
|---|---|
| NVDA + Firefox | “2025, Q1, column 6, 1,240” |
| JAWS + Chrome | “2025 Q1, 1,240” |
| VoiceOver + Safari | “2025, Q1, 1,240” (verify; support varies) |
| Moving within the same group | Only the changed lower header announced |
Step-by-step implementation
Permalink to "Step-by-step implementation"- Sketch the header grid. Draw the header rows on paper with every span marked. If any spanning header covers columns that are not contiguous, or a lower header belongs to two upper headers, you need headers/id.
- Add colgroup elements. One
<colgroup span>per top-level group (plus one for the row-header column) gives browsers the group boundaries thatscope="colgroup"refers to. - Mark the top row. Spanning
thelements getcolspanandscope="colgroup". The corner cell above row headers is atd(empty) or athdescribing the row headers. - Mark the lower row. Each
thgetsscope="col". - Test announcements. Move through data cells with table navigation in NVDA, JAWS and VoiceOver. If any reader drops the top level, switch to headers/id for that table.
Integration context
Permalink to "Integration context"Scope and headers basics are covered in correct usage of scope and headers in complex tables. Captions are in writing table captions and summaries. For tables with nested row and column hierarchies, see accessible pivot tables. Automated checks are described in accessibility tree and API inspection.
Gotchas
Permalink to "Gotchas"ARIA grids do not support colspan headers well. aria-colspan exists, but multi-level column headers in ARIA grids are poorly supported; prefer native tables for complex headers.
Sticky multi-row headers. Each header row needs its own sticky top offset; measure them rather than hard-coding.
Sorting. Sort controls belong on the lowest header level; sorting by “2025” as a group is meaningless.
Edge cases
Permalink to "Edge cases"- Three levels. Year › Quarter › Month across 36 columns is hard for everyone; offer a filter for the year instead.
- Empty corner cells. Use
tdfor empty corners; an emptythcreates a nameless header. - Responsive layouts. Card layouts flatten headers; combine both levels in each label (“2025 Q1”).
Worked example
Permalink to "Worked example"An annual report table showed plan and actual figures under each of four regions, using a styled top row of td cells. Screen reader users heard “Plan, 1,200” without knowing which region. The team changed the top row to th scope="colgroup", added colgroup elements and tested; NVDA and JAWS announced “North, Plan, 1,200”. VoiceOver dropped the region in one case, so the team added headers/id to that table and documented the fallback.
Common mistakes
Permalink to "Common mistakes"- Styled td cells as top headers.
- Missing colgroup elements.
- Empty th corners.
- ARIA grids for complex headers.
Testing checklist
Permalink to "Testing checklist"Design system notes
Permalink to "Design system notes"Let table components accept nested column definitions and generate colgroup, colspan and scope automatically, falling back to headers/id when spans are irregular.
Testing it with a screen reader
Permalink to "Testing it with a screen reader"Open the table in NVDA with Firefox, press T to jump to it and listen to the caption. Move into the first data cell with Ctrl+Alt+Arrow keys. On the first move, NVDA should announce the row header and both column header levels. Move right within the same year and check that only the quarter changes; move across the year boundary and confirm that the new year is announced. Repeat in JAWS with Chrome and in VoiceOver with Safari using VO+Arrow keys. Write down which readers drop the top level; if any do, switch that table to explicit headers and id and test again. Keep these results in your screen reader test matrix.
Row groups with scope rowgroup
Permalink to "Row groups with scope rowgroup"The same technique works vertically. When rows fall into groups — regions containing cities, or departments containing teams — wrap each group in its own <tbody> and put a th with scope="rowgroup" in the first row of the group. Screen readers then announce the group header along with the row header as users move down the column: “North, Oslo, 1,240”. This pairs well with multi-level column headers in reports where both axes are hierarchical, though at that point a pivot table pattern with explicit headers and id is often more reliable. Keep rowspan on the group header only when the visual design needs it; the scope and tbody structure is what carries the meaning. As with column groups, verify in each screen reader, because row group support is less consistent than column group support.
FAQ
Permalink to "FAQ"Is scope colgroup well supported?
In NVDA and JAWS, yes, with colgroup elements. VoiceOver support is less consistent; test.
When must I use headers and id?
When spans are irregular or a cell relates to headers that scope cannot express.
Can I use aria-describedby instead?
No. It changes the description, not header association, and is read inconsistently.
What about row groups?
Use scope=“rowgroup” with tbody elements for the same pattern on rows.
How do I keep this maintainable?
Generate markup from data definitions rather than hand-writing spans.
Should I split complex tables?
Often yes. Two simple tables can be easier for everyone than one with three header levels.
Related
Permalink to "Related"- Correct usage of scope and headers — the foundations
- Accessible pivot tables — nested headers on both axes
- Writing table captions and summaries — context for complex tables