Complex Tables (Multi-Level Headers)
Create accessible complex data tables with multi-level headers using scope, id, and headers attributes.
The Bad (Inaccessible)
<table>
<tr>
<td colspan="2">Q1</td>
<td colspan="2">Q2</td>
</tr>
<tr>
<td>Jan</td>
<td>Feb</td>
<td>Mar</td>
<td>Apr</td>
</tr>
</table>
Accessibility-Ready Code
<!-- Gold Standard: Multi-level headers with id/headers -->
<table class="complex-table">
<caption>
Quarterly Sales Report 2024
<span class="sr-only">
- Sales figures broken down by region and quarter
</span>
</caption>
<thead>
<!-- First header row: Quarter span -->
<tr>
<th scope="col" id="region">Region</th>
<th scope="col" id="q1" colspan="3">Q1 2024</th>
<th scope="col" id="q2" colspan="3">Q2 2024</th>
</tr>
<!-- Second header row: Months -->
<tr>
<th scope="col" headers="region"></th>
<th scope="col" id="jan" headers="q1">January</th>
<th scope="col" id="feb" headers="q1">February</th>
<th scope="col" id="mar" headers="q1">March</th>
<th scope="col" id="apr" headers="q2">April</th>
<th scope="col" id="may" headers="q2">May</th>
<th scope="col" id="jun" headers="q2">June</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" id="north">North Region</th>
<!-- Q1 Data -->
<td headers="north q1 jan">$45,000</td>
<td headers="north q1 feb">$52,000</td>
<td headers="north q1 mar">$48,000</td>
<!-- Q2 Data -->
<td headers="north q2 apr">$50,000</td>
<td headers="north q2 may">$55,000</td>
<td headers="north q2 jun">$60,000</td>
</tr>
<tr>
<th scope="row" id="south">South Region</th>
<!-- Q1 Data -->
<td headers="south q1 jan">$38,000</td>
<td headers="south q1 feb">$42,000</td>
<td headers="south q1 mar">$45,000</td>
<!-- Q2 Data -->
<td headers="south q2 apr">$47,000</td>
<td headers="south q2 may">$51,000</td>
<td headers="south q2 jun">$53,000</td>
</tr>
<tr>
<th scope="row" id="east">East Region</th>
<!-- Q1 Data -->
<td headers="east q1 jan">$62,000</td>
<td headers="east q1 feb">$58,000</td>
<td headers="east q1 mar">$65,000</td>
<!-- Q2 Data -->
<td headers="east q2 apr">$68,000</td>
<td headers="east q2 may">$72,000</td>
<td headers="east q2 jun">$75,000</td>
</tr>
<tr>
<th scope="row" id="west">West Region</th>
<!-- Q1 Data -->
<td headers="west q1 jan">$41,000</td>
<td headers="west q1 feb">$44,000</td>
<td headers="west q1 mar">$47,000</td>
<!-- Q2 Data -->
<td headers="west q2 apr">$49,000</td>
<td headers="west q2 may">$52,000</td>
<td headers="west q2 jun">$56,000</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<th headers="q1 jan" colspan="3">$629,000</th>
<th headers="q2 apr" colspan="3">$732,000</th>
</tr>
</tfoot>
</table>
// Gold Standard: Multi-level headers with id/headers
table.complex-table
caption
| Quarterly Sales Report 2024
span.sr-only - Sales figures broken down by region and quarter
thead
// First header row: Quarter span
tr
th(scope="col" id="region") Region
th(scope="col" id="q1" colspan="3") Q1 2024
th(scope="col" id="q2" colspan="3") Q2 2024
// Second header row: Months
tr
th(scope="col" headers="region")
th(scope="col" id="jan" headers="q1") January
th(scope="col" id="feb" headers="q1") February
th(scope="col" id="mar" headers="q1") March
th(scope="col" id="apr" headers="q2") April
th(scope="col" id="may" headers="q2") May
th(scope="col" id="jun" headers="q2") June
tbody
tr
th(scope="row" id="north") North Region
// Q1 Data
td(headers="north q1 jan") $45,000
td(headers="north q1 feb") $52,000
td(headers="north q1 mar") $48,000
// Q2 Data
td(headers="north q2 apr") $50,000
td(headers="north q2 may") $55,000
td(headers="north q2 jun") $60,000
tr
th(scope="row" id="south") South Region
// Q1 Data
td(headers="south q1 jan") $38,000
td(headers="south q1 feb") $42,000
td(headers="south q1 mar") $45,000
// Q2 Data
td(headers="south q2 apr") $47,000
td(headers="south q2 may") $51,000
td(headers="south q2 jun") $53,000
tr
th(scope="row" id="east") East Region
// Q1 Data
td(headers="east q1 jan") $62,000
td(headers="east q1 feb") $58,000
td(headers="east q1 mar") $65,000
// Q2 Data
td(headers="east q2 apr") $68,000
td(headers="east q2 may") $72,000
td(headers="east q2 jun") $75,000
tr
th(scope="row" id="west") West Region
// Q1 Data
td(headers="west q1 jan") $41,000
td(headers="west q1 feb") $44,000
td(headers="west q1 mar") $47,000
// Q2 Data
td(headers="west q2 apr") $49,000
td(headers="west q2 may") $52,000
td(headers="west q2 jun") $56,000
tfoot
tr
th(scope="row") Total
th(headers="q1 jan" colspan="3") $629,000
th(headers="q2 apr" colspan="3") $732,000
const ComplexTable = ({ salesData }) => {
return (
<table className="complex-table">
<caption>
Quarterly Sales Report 2024
<span className="sr-only">
- Sales figures broken down by region and quarter
</span>
</caption>
<thead>
<tr>
<th scope="col" id="region">Region</th>
<th scope="col" id="q1" colSpan={3}>Q1 2024</th>
<th scope="col" id="q2" colSpan={3}>Q2 2024</th>
</tr>
<tr>
<th scope="col" headers="region"></th>
<th scope="col" id="jan" headers="q1">January</th>
<th scope="col" id="feb" headers="q1">February</th>
<th scope="col" id="mar" headers="q1">March</th>
<th scope="col" id="apr" headers="q2">April</th>
<th scope="col" id="may" headers="q2">May</th>
<th scope="col" id="jun" headers="q2">June</th>
</tr>
</thead>
<tbody>
{salesData.map((region) => (
<tr key={region.id}>
<th scope="row" id={region.id}>{region.name}</th>
{region.monthlySales.map((sales, index) => {
const quarter = index < 3 ? 'q1' : 'q2';
const monthIds = ['jan', 'feb', 'mar', 'apr', 'may', 'jun'];
return (
<td
key={index}
headers={`${region.id} ${quarter} ${monthIds[index]}`}
>
${sales.toLocaleString()}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<th headers="q1 jan" colSpan={3}>$629,000</th>
<th headers="q2 apr" colSpan={3}>$732,000</th>
</tr>
</tfoot>
</table>
);
};
<template>
<table class="complex-table">
<caption>
Quarterly Sales Report 2024
<span class="sr-only">
- Sales figures broken down by region and quarter
</span>
</caption>
<thead>
<tr>
<th scope="col" id="region">Region</th>
<th scope="col" id="q1" :colspan="3">Q1 2024</th>
<th scope="col" id="q2" :colspan="3">Q2 2024</th>
</tr>
<tr>
<th scope="col" headers="region"></th>
<th scope="col" id="jan" headers="q1">January</th>
<th scope="col" id="feb" headers="q1">February</th>
<th scope="col" id="mar" headers="q1">March</th>
<th scope="col" id="apr" headers="q2">April</th>
<th scope="col" id="may" headers="q2">May</th>
<th scope="col" id="jun" headers="q2">June</th>
</tr>
</thead>
<tbody>
<tr v-for="region in salesData" :key="region.id">
<th scope="row" :id="region.id">{{ region.name }}</th>
<td
v-for="(sales, index) in region.monthlySales"
:key="index"
:headers="`${region.id} ${index < 3 ? 'q1' : 'q2'} ${monthIds[index]}`"
>
{{ $n(sales, 'currency') }}
</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<th headers="q1 jan" :colspan="3">$629,000</th>
<th headers="q2 apr" :colspan="3">$732,000</th>
</tr>
</tfoot>
</table>
</template>
<script>
export default {
data() {
return {
monthIds: ['jan', 'feb', 'mar', 'apr', 'may', 'jun']
}
}
}
</script>
<table className="min-w-full border-collapse">
<caption>
Quarterly Sales Report 2024
<span className="sr-only">
- Sales figures broken down by region and quarter
</span>
</caption>
<thead className="bg-gray-100">
<tr>
<th scope="col" id="region" className="border border-gray-300 px-4 py-2">
Region
</th>
<th
scope="col"
id="q1"
colSpan={3}
className="border border-gray-300 px-4 py-2 text-center"
>
Q1 2024
</th>
<th
scope="col"
id="q2"
colSpan={3}
className="border border-gray-300 px-4 py-2 text-center"
>
Q2 2024
</th>
</tr>
<tr>
<th scope="col" headers="region" className="border border-gray-300 px-4 py-2"></th>
<th scope="col" id="jan" headers="q1" className="border border-gray-300 px-4 py-2">
January
</th>
<th scope="col" id="feb" headers="q1" className="border border-gray-300 px-4 py-2">
February
</th>
<th scope="col" id="mar" headers="q1" className="border border-gray-300 px-4 py-2">
March
</th>
<th scope="col" id="apr" headers="q2" className="border border-gray-300 px-4 py-2">
April
</th>
<th scope="col" id="may" headers="q2" className="border border-gray-300 px-4 py-2">
May
</th>
<th scope="col" id="jun" headers="q2" className="border border-gray-300 px-4 py-2">
June
</th>
</tr>
</thead>
<tbody>
{salesData.map((region) => (
<tr key={region.id} className="even:bg-gray-50">
<th scope="row" id={region.id} className="border border-gray-300 px-4 py-2 font-medium">
{region.name}
</th>
{region.monthlySales.map((sales, index) => {
const quarter = index < 3 ? 'q1' : 'q2';
const monthIds = ['jan', 'feb', 'mar', 'apr', 'may', 'jun'];
return (
<td
key={index}
headers={`${region.id} ${quarter} ${monthIds[index]}`}
className="border border-gray-300 px-4 py-2"
>
${sales.toLocaleString()}
</td>
);
})}
</tr>
))}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<th scope="row" className="border border-gray-300 px-4 py-2">Total</th>
<th
headers="q1 jan"
colSpan={3}
className="border border-gray-300 px-4 py-2"
>
$629,000
</th>
<th
headers="q2 apr"
colSpan={3}
className="border border-gray-300 px-4 py-2"
>
$732,000
</th>
</tr>
</tfoot>
</table>
The Standard
Complex tables have multi-level headers that require careful markup. Use id, headers, and scope attributes to establish relationships between headers and data cells.
WCAG Criteria
- 1.3.1 Info and Relationships: Multi-level header structure.
- 4.1.2 Name, Role, Value: Proper header associations.
- 2.4.6 Headings and Labels: Clear table hierarchy.
β The Bad (Inaccessible)
Whatβs Wrong?
- Missing
headersattribute: Screen reader canβt map data to all headers. - Using only
colspan: No semantic meaning, just visual. - Empty
<th>cells: Confusing navigation. - Inconsistent header IDs: Broken references.
β The Good (Accessibility-Ready Code)
Why This Works
- Header Hierarchy: Level 1 (quarter) β Level 2 (month).
- headers Attribute: Links cells to ALL relevant header IDs.
- Scope Attributes: Defines column and row headers.
- Unique IDs: Each header gets a unique identifier.
Accessibility Checklist
- Give each unique header a unique
id. - [ ] Use
colspanon headers that span multiple columns. - Link data cells to all applicable headers with
headers="id1 id2 id3". - Use
scope="col"for column headers. - Use
scope="row"for row headers. - Include
aria-labelledbyor sr-only text for context.
<table>
<caption>Quarterly Sales</caption>
<thead>
<!-- Level 1 -->
<tr>
<th scope="col" id="region">Region</th>
<th scope="col" id="q1" colspan="3">Q1</th>
</tr>
<!-- Level 2 -->
<tr>
<th scope="col" headers="region"></th>
<th scope="col" id="jan" headers="q1">Jan</th>
<th scope="col" id="feb" headers="q1">Feb</th>
<th scope="col" id="mar" headers="q1">Mar</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row" id="north">North</th>
<!-- Headers attribute links to ALL applicable headers -->
<td headers="north q1 jan">$45,000</td>
<td headers="north q1 feb">$52,000</td>
<td headers="north q1 mar">$48,000</td>
</tr>
</tbody>
</table>
Technical Deep Dive
Screen Reader Announcements
- NVDA: βNorth Region Q1 January $45,000β
- JAWS: βNorth Region Q1 January $45,000β
- VoiceOver: βNorth Region, Q1, January, $45,000β
Best Practice: The headers Attribute Format
For complex tables with multi-level headers, the headers attribute is essential. The format is headers="row_id column_id subcolumn_id" which links each data cell to all its applicable header IDs. This ensures screen readers announce the complete context for each cell, such as βNorth Region Q1 January $45,000β instead of just β$45,000β.
Interactive Behavioral Lab
Interactive Sandbox
π¬ Technical Internals
Understand how this component is processed by the browser and Assistive Technology (AT). This section bridges the gap between visual code and the hidden logic that powers accessibility.
π² Accessibility Tree
The data structure used by screen readers to "see" your page. It translates HTML roles and attributes into standardized objects.
βοΈ Event Logic
Expected behavioral standards for keyboard navigation and state transitions. Crucial for users who don't use a mouse.
- Focus: Highlights via
:focus-visible - Activation: Responds to
EnterandSpace - Role: Identified as
Complex Table