Tables with Colspan and Rowspan
Create accessible tables with colspan and rowspan using proper id and headers attributes to maintain context for screen reader users.
The Bad (Inaccessible)
<table>
<tr>
<td colspan="2">Q1 Sales</td>
</tr>
<tr>
<td>Product A</td>
<td>$10,000</td>
</tr>
<tr>
<td rowspan="2">Total</td>
<td>$10,000</td>
</tr>
<tr>
<td>$15,000</td>
</tr>
</table>
Accessibility-Ready Code
<!-- Gold Standard: Colspan/rowspan with proper headers -->
<table class="spanning-table">
<caption>
Quarterly Sales Report by Region
<span class="sr-only">
- Sales figures with regional totals and quarterly summaries
</span>
</caption>
<thead>
<!-- Header row with column spanning -->
<tr>
<th scope="col" id="region">Region</th>
<th scope="col" id="q1" colspan="3">Q1 2024</th>
<th scope="col" id="total">Total</th>
</tr>
<!-- Sub-headers under colspan -->
<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" headers="total"></th>
</tr>
</thead>
<tbody>
<!-- East Region (spans 2 rows) -->
<tr>
<th scope="row" id="east" rowspan="2">East Region</th>
<td headers="east q1 jan">$45,000</td>
<td headers="east q1 feb">$52,000</td>
<td headers="east q1 mar">$48,000</td>
<td headers="east total">$145,000</td>
</tr>
<!-- Second row of East Region (row header spans from above) -->
<tr>
<td headers="east q1 jan">$50,000</td>
<td headers="east q1 feb">$55,000</td>
<td headers="east q1 mar">$60,000</td>
<td headers="east total">$165,000</td>
</tr>
<!-- West Region -->
<tr>
<th scope="row" id="west" rowspan="2">West Region</th>
<td headers="west q1 jan">$38,000</td>
<td headers="west q1 feb">$42,000</td>
<td headers="west q1 mar">$45,000</td>
<td headers="west total">$125,000</td>
</tr>
<!-- Second row of West Region -->
<tr>
<td headers="west q1 jan">$47,000</td>
<td headers="west q1 feb">$51,000</td>
<td headers="west q1 mar">$53,000</td>
<td headers="west total">$151,000</td>
</tr>
</tbody>
<tfoot>
<!-- Grand total row with colspan -->
<tr>
<th scope="row" colspan="4">Grand Total (All Regions)</th>
<td headers="grand-total">$586,000</td>
</tr>
</tfoot>
</table>
// Gold Standard: Colspan/rowspan with proper headers
table.spanning-table
caption
| Quarterly Sales Report by Region
span.sr-only - Sales figures with regional totals and quarterly summaries
thead
// Header row with column spanning
tr
th(scope="col" id="region") Region
th(scope="col" id="q1" colspan="3") Q1 2024
th(scope="col" id="total") Total
// Sub-headers under colspan
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" headers="total")
tbody
// East Region (spans 2 rows)
tr
th(scope="row" id="east" rowspan="2") East Region
td(headers="east q1 jan") $45,000
td(headers="east q1 feb") $52,000
td(headers="east q1 mar") $48,000
td(headers="east total") $145,000
// Second row of East Region
tr
td(headers="east q1 jan") $50,000
td(headers="east q1 feb") $55,000
td(headers="east q1 mar") $60,000
td(headers="east total") $165,000
// West Region
tr
th(scope="row" id="west" rowspan="2") West Region
td(headers="west q1 jan") $38,000
td(headers="west q1 feb") $42,000
td(headers="west q1 mar") $45,000
td(headers="west total") $125,000
// Second row of West Region
tr
td(headers="west q1 jan") $47,000
td(headers="west q1 feb") $51,000
td(headers="west q1 mar") $53,000
td(headers="west total") $151,000
tfoot
// Grand total row with colspan
tr
th(scope="row" colspan="4") Grand Total (All Regions)
td(headers="grand-total") $586,000
const SpanningTable = ({ salesData }) => {
return (
<table className="spanning-table">
<caption>
Quarterly Sales Report by Region
<span className="sr-only">
- Sales figures with regional totals and quarterly summaries
</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="total">Total</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" headers="total"></th>
</tr>
</thead>
<tbody>
{salesData.map((region) => (
<React.Fragment key={region.id}>
<tr>
<th scope="row" id={region.id} rowSpan={2}>
{region.name}
</th>
{region.months.map((month, idx) => (
<td
key={`${region.id}-${idx}`}
headers={`${region.id} q1 ${month.id}`}
>
${month.sales.toLocaleString()}
</td>
))}
<td headers={`${region.id} total`}>
${region.total.toLocaleString()}
</td>
</tr>
<tr>
{region.months.map((month, idx) => (
<td
key={`${region.id}-${idx}-2`}
headers={`${region.id} q1 ${month.id}`}
>
${month.sales2.toLocaleString()}
</td>
))}
<td headers={`${region.id} total-2`}>
${region.total2.toLocaleString()}
</td>
</tr>
</React.Fragment>
))}
</tbody>
<tfoot>
<tr>
<th scope="row" colSpan={4}>Grand Total (All Regions)</th>
<td headers="grand-total">
${salesData.reduce((sum, r) => sum + r.grandTotal, 0).toLocaleString()}
</td>
</tr>
</tfoot>
</table>
);
};
<template>
<table class="spanning-table">
<caption>
Quarterly Sales Report by Region
<span class="sr-only">
- Sales figures with regional totals and quarterly summaries
</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="total">Total</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" headers="total"></th>
</tr>
</thead>
<tbody>
<template v-for="region in salesData" :key="region.id">
<tr>
<th scope="row" :id="region.id" :rowspan="2">
{{ region.name }}
</th>
<td
v-for="(month, idx) in region.months"
:key="`${region.id}-${idx}`"
:headers="`${region.id} q1 ${month.id}`"
>
{{ $n(month.sales, 'currency') }}
</td>
<td :headers="`${region.id} total`">
{{ $n(region.total, 'currency') }}
</td>
</tr>
<tr>
<td
v-for="(month, idx) in region.months"
:key="`${region.id}-${idx}-2`"
:headers="`${region.id} q1 ${month.id}`"
>
{{ $n(month.sales2, 'currency') }}
</td>
<td :headers="`${region.id} total-2`">
{{ $n(region.total2, 'currency') }}
</td>
</tr>
</template>
</tbody>
<tfoot>
<tr>
<th scope="row" :colspan="4">Grand Total (All Regions)</th>
<td headers="grand-total">
{{ $n(grandTotal, 'currency') }}
</td>
</tr>
</tfoot>
</table>
</template>
<script>
export default {
computed: {
grandTotal() {
return this.salesData.reduce((sum, r) => sum + r.grandTotal, 0);
}
}
}
</script>
<table className="min-w-full border-collapse">
<caption>
Quarterly Sales Report by Region
<span className="sr-only">
- Sales figures with regional totals and quarterly summaries
</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="total" className="border border-gray-300 px-4 py-2">
Total
</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" headers="total" className="border border-gray-300 px-4 py-2"></th>
</tr>
</thead>
<tbody>
{salesData.map((region) => (
<>
<tr key={`${region.id}-1`} className="even:bg-gray-50">
<th
scope="row"
id={region.id}
rowSpan={2}
className="border border-gray-300 px-4 py-2 font-medium"
>
{region.name}
</th>
{region.months.map((month, idx) => (
<td
key={`${region.id}-${idx}`}
headers={`${region.id} q1 ${month.id}`}
className="border border-gray-300 px-4 py-2"
>
${month.sales.toLocaleString()}
</td>
))}
<td
headers={`${region.id} total`}
className="border border-gray-300 px-4 py-2"
>
${region.total.toLocaleString()}
</td>
</tr>
<tr key={`${region.id}-2`} className="even:bg-gray-50">
{region.months.map((month, idx) => (
<td
key={`${region.id}-${idx}-2`}
headers={`${region.id} q1 ${month.id}`}
className="border border-gray-300 px-4 py-2"
>
${month.sales2.toLocaleString()}
</td>
))}
<td
headers={`${region.id} total-2`}
className="border border-gray-300 px-4 py-2"
>
${region.total2.toLocaleString()}
</td>
</tr>
</>
))}
</tbody>
<tfoot className="bg-gray-50">
<tr>
<th
scope="row"
colSpan={4}
className="border border-gray-300 px-4 py-2"
>
Grand Total (All Regions)
</th>
<td
headers="grand-total"
className="border border-gray-300 px-4 py-2"
>
${salesData.reduce((sum, r) => sum + r.grandTotal, 0).toLocaleString()}
</td>
</tr>
</tfoot>
</table>
The Standard
Tables with colspan and rowspan require special attention to accessibility. When cells span multiple rows or columns, you must use id and headers attributes to maintain context for screen reader users.
WCAG Criteria
- 1.3.1 Info and Relationships: Proper header associations for spanned cells.
- 4.1.2 Name, Role, Value: Explicit header relationships.
- 2.4.6 Headings and Labels: Clear table structure with captions.
β The Bad (Inaccessible)
Whatβs Wrong?
- Missing
headersattribute: Screen readers canβt determine which headers apply to spanned cells. - Using
<td>for headers: No semantic meaning for row headers. - No
scopeattributes: Ambiguous header relationships. - Missing caption: No context for table purpose.
- Colspan/rowspan without IDs: Breaks navigation for screen readers.
β The Good (Accessibility-Ready Code)
Why This Works
- Unique IDs for All Headers: Every header cell gets a unique
id. - headers Attribute: Links data cells to ALL applicable headers.
- rowspan with Proper IDs: Row headers that span still maintain context via
headers. - colspan with Sub-headers: Creates hierarchical header structure.
- Scope Attributes: Defines direction (col/row) for each header.
Accessibility Checklist
- Give every
<th>a uniqueidattribute. - Use
colspanon headers that span multiple columns. - Use
rowspanon headers that span multiple rows. - Link ALL data cells to headers with
headers="id1 id2 id3". - Include sub-headers under colspan headers with proper
headersreference. - Use
scope="col"for column headers. - Use
scope="row"for row headers. - Add descriptive
<caption>for table context.
<table>
<caption>Sales Report</caption>
<thead>
<!-- Colspan header -->
<tr>
<th scope="col" id="region">Region</th>
<th scope="col" id="q1" colspan="3">Q1 2024</th>
</tr>
<!-- Sub-headers reference the colspan -->
<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>
<!-- Rowspan header spans 2 rows -->
<tr>
<th scope="row" id="east" rowspan="2">East</th>
<td headers="east q1 jan">$45,000</td>
<td headers="east q1 feb">$52,000</td>
<td headers="east q1 mar">$48,000</td>
</tr>
<!-- Second row: cell still references east header -->
<tr>
<td headers="east q1 jan">$50,000</td>
<td headers="east q1 feb">$55,000</td>
<td headers="east q1 mar">$60,000</td>
</tr>
</tbody>
</table>
Technical Deep Dive
Screen Reader Announcements
- NVDA: βEast Region, Q1 January, $45,000β (reads all headers correctly)
- VoiceOver: βEast Region, Q1, January, $45,000β (navigates spanned cells properly)
- JAWS: βEast Region row spanned 2, Q1 January, $45,000β (announces spanning)
Best Practice: Colspan and Rowspan Headers
When using colspan and rowspan, always provide unique id attributes on all header cells and reference them with the headers attribute on data cells. For colspan headers, create sub-headers that reference the parent header with headers="parent_id". For rowspan headers, ensure all rows reference the row header ID, even though the header cell visually spans multiple rows.
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
Table with Colspan/Rowspan