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.

HTML6 min readConcept 24 of 42

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.

Conference schedule
TimeRoom ARoom B
09:00Keynote (all rooms)
10:30CSS WorkshopJS Fundamentals
14:00Accessibility TalkPerformance Deep Dive

rowspan: one cell spans multiple rows

"Seoul" uses rowspan="2". The next row has no City cell in its markup.

Regional sales
CityDistrictRevenue
SeoulGangnam$4.2 M
Mapo$2.8 M
TokyoShibuya$6.1 M

complex tables: headers + id over scope

When a group header (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.
Simple spanning (a single row header with no group above it) still works fine with 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.

  1. 1A table row has three columns. You add colspan="2" to the first cell. How many <td> elements should that row now contain?
  2. 2A <th rowspan="3"> appears in the first cell of a row in a four-column table. How many <td> cells do each of the NEXT two rows need?
  3. 3When does scope become insufficient and the headers attribute become necessary?

Remember this

  • For every slot a colspan or rowspan claims 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 id on <th> and headers="id1 id2" on <td> when a cell is under multiple levels of header and scope is 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.