Spanning & Accessible Tables
colspan and rowspan let a single cell occupy multiple columns or rows. Spanning is what allows HTML tables to match the merged-cell layouts that data naturally suggests, and the headers attribute keeps that structure navigable by screen readers.
What it is
colspan and rowspan are attributes on <td> and <th> that extend a cell across multiple column or row slots. colspan="3" means: this cell occupies three consecutive columns. rowspan="2" means: this cell continues down into the next row as well. The number is the count of slots consumed, including the cell's own home slot.
For header cells that span columns, scope="colgroup" declares that the header covers an entire <colgroup> of columns. For header cells that span rows, scope="rowgroup" covers an entire row group. When a table is complex enough that scope cannot unambiguously match a header to its data cells, the headers attribute on each <td> provides the link explicitly: it holds a space-separated list of id values from the relevant <th> elements.
Why it matters
Real-world tabular data often has grouped structure: a conference with rooms that share a keynote slot, a product catalogue with variants that share a name, a timetable with days that span multiple time blocks. colspan and rowspan let the HTML table reflect that structure rather than flatten it into duplicated or empty cells. A visually merged cell that is not actually merged in the markup misleads assistive technology about the table's shape.
The headers attribute is the escape hatch for complex spanning. When a <td> sits under a column-spanning <th>, screen readers cannot always infer the association from scope alone, especially in multi-level header situations. Setting headers="q1 revenue" on each data cell (where q1 and revenue are ids of the relevant header cells) creates an unambiguous, auditable link that survives any layout complexity.
How it works
Add colspan or rowspan to the cell that should expand, then remove the displaced cells from the affected rows or columns. If you colspan="2" a cell, the cell that would have been in the next column of that row must not exist in the markup. If you rowspan="2" a cell in the first row, the second row has one fewer <td> than the other rows. Getting the cell count wrong causes the layout to misalign.
For a header that spans two columns and needs to associate with data below, give the <th> an id, then add a headers attribute to each data cell in those columns. For a straightforward column header, scope="col" is enough. Use headers when you have: grouped column headers above individual column headers, or a row header that labels only a subset of cells in that row.
The <colgroup> and <col> elements give you a hook for styling entire columns in CSS without touching every cell. A <col> inside <colgroup> targets the column at its index position. A <th> that precedes a <colgroup> with scope="colgroup" declares itself the header for every column in that group.
Try it
See how colspan and rowspan shape the table grid, and how the headers attribute links data cells to their spanning headers.
<!-- colspan: keynote spans both rooms --><tr> <th scope="row">09:00</th> <td colspan="2">Keynote</td></tr> <!-- rowspan: city spans two districts --><tr> <th scope="row" rowspan="2">Seoul</th> <td>Gangnam</td> <td>$4.2 M</td></tr><tr> <!-- Seoul cell already here via rowspan --> <td>Mapo</td> <td>$2.8 M</td></tr> <!-- complex: headers + id --><th id="q1" colspan="2">Q1</th><th id="rev">Revenue</th><th id="units">Units</th><td headers="q1 rev">$12k</td><td headers="q1 units">240</td>
colspan: one cell spans multiple columns
The keynote row uses colspan="2". Room A and Room B cells are removed from that row.
| Time | Room A | Room B |
|---|---|---|
| 09:00 | Keynote (all rooms) | |
| 10:30 | CSS Workshop | JS Fundamentals |
| 14:00 | Accessibility Talk | Performance Deep Dive |
rowspan: one cell spans multiple rows
"Seoul" uses rowspan="2". The next row has no City cell in its markup.
| City | District | Revenue |
|---|---|---|
| Seoul | Gangnam | $4.2 M |
| Mapo | $2.8 M | |
| Tokyo | Shibuya | $6.1 M |
complex tables: headers + id over scope
id="q1") spans two columns, each data cell below it needs headers="q1 rev" or headers="q1 units" to express both associations.scope alone cannot handle two-level headers: it resolves one direction (col or row) but cannot point at two separate header cells at once.scope="row" or scope="col".Check yourself
Pick an answer to lock it in, then read why. Getting one wrong is part of how it sticks.
Remember this
- For every slot a
colspanorrowspanclaims beyond the first, remove one cell from the affected row or column. scope="colgroup"marks a<th>as the header for all columns in a<colgroup>;scope="rowgroup"does the same for a row group.- Use
idon<th>andheaders="id1 id2"on<td>when a cell is under multiple levels of header andscopeis ambiguous. - WCAG 1.3.1 requires that visual spanning be conveyed programmatically: a merged cell with no accessible header association fails the criterion.
Done with this concept?
Mark it complete to track your progress. No login needed.