Complex tables: caption, scope and spanning cells

Make larger tables understandable with captions, explicit header scope and cells that span rows or columns.

  • Beginner
  • 7 min read
  • Updated 17 Oct 2026

What you will learn

  • Add captions and header scope
  • Span cells without losing meaning
  • Know when to split a table instead

Before you start

Simple tables work almost by themselves. As soon as a table has headers on two sides, grouped columns or merged cells, you need to tell the browser exactly which headers belong to which cells. Otherwise the table is readable on screen but confusing, or wrong, when read aloud.

caption: the table's title

caption names the table. It must be the first child of table:

HTML
<table>
  <caption>Class prices, November 2026</caption>
  ...
</table>

A caption is visible to everyone, and screen readers announce it when entering the table, so people know whether it is worth exploring. It is better than a heading or paragraph above the table, because it is programmatically tied to the table.

The caption can hold a short summary to help people understand a complex table:

HTML
<caption>
  Class prices, November 2026
  <small>Prices per person. Members save 10%.</small>
</caption>

You can move the caption below the table with CSS caption-side: bottom without changing the markup order.

scope: which cells a header applies to

The scope attribute on a th states the direction of the header:

ValueHeader applies to
colThe cells below it in the same column
rowThe cells to its right in the same row
colgroupAll the columns it spans
rowgroupAll the rows in its row group
HTML
<table>
  <caption>Opening hours</caption>
  <thead>
    <tr>
      <th scope="col">Day</th>
      <th scope="col">Opens</th>
      <th scope="col">Closes</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Monday</th>
      <td>7am</td>
      <td>4pm</td>
    </tr>
    <tr>
      <th scope="row">Sunday</th>
      <td>Closed</td>
      <td>Closed</td>
    </tr>
  </tbody>
</table>

Browsers can often guess the direction in a table this simple, but scope removes the guesswork. Make it a habit on every th.

colspan and rowspan

A cell can stretch across several columns with colspan, or several rows with rowspan:

HTML
<!DOCTYPE html>
<html lang="en-GB">
<head>
  <meta charset="utf-8">
  <title>Class timetable</title>
  <style>
    table { border-collapse: collapse; }
    th, td { border: 1px solid #888; padding: 0.4rem 0.75rem; text-align: left; }
  </style>
</head>
<body>
  <table>
    <caption>Weekend class timetable</caption>
    <thead>
      <tr>
        <td></td>
        <th scope="colgroup" colspan="2">Saturday</th>
        <th scope="colgroup" colspan="2">Sunday</th>
      </tr>
      <tr>
        <td></td>
        <th scope="col">Morning</th>
        <th scope="col">Afternoon</th>
        <th scope="col">Morning</th>
        <th scope="col">Afternoon</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Sourdough</th>
        <td>Basics</td>
        <td>Advanced</td>
        <td colspan="2">Full-day masterclass</td>
      </tr>
      <tr>
        <th scope="row">Pastry</th>
        <td>Croissants</td>
        <td>Danish</td>
        <td>Croissants</td>
        <td>No class</td>
      </tr>
    </tbody>
  </table>
</body>
</html>

"Saturday" and "Sunday" each cover two columns, so they use colspan="2" and scope="colgroup". The Sunday masterclass covers both Sunday slots, so its td uses colspan="2". A screen reader reaching "Danish" can announce "Saturday, Afternoon, Pastry, Danish".

The top-left cell is an empty td, not a th, because it is not a header for anything.

Counting columns

With spanning cells, every row must still add up to the same number of columns. In the timetable, each row is five columns wide: the row header plus four slots, with a colspan="2" cell counting as two.

When a cell spans rows with rowspan, it occupies a slot in the rows below, so those rows contain one fewer cell in the markup:

HTML
<table>
  <caption>Shop staff rota</caption>
  <thead>
    <tr>
      <th scope="col">Shift</th>
      <th scope="col">Day</th>
      <th scope="col">Staff</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row" rowspan="2">Early</th>
      <td>Monday</td>
      <td>Priya</td>
    </tr>
    <tr>
      <td>Tuesday</td>
      <td>Tom</td>
    </tr>
  </tbody>
</table>

The second body row has only two cells, because "Early" already fills its first column.

When a spanning table looks wrong in the browser, cells have shifted because a row has too many or too few. Sketch the grid on paper and count each row, including the slots taken by rowspan cells from above.

colgroup and col

colgroup and col describe columns as a whole. They go after the caption and before thead. Their main practical use is styling whole columns with a few CSS properties, such as width and background:

HTML
<style>
  .figures { background: #f2f6fa; }
</style>
<table>
  <caption>Loaves sold</caption>
  <colgroup>
    <col>
    <col span="2" class="figures">
  </colgroup>
  <thead>
    <tr>
      <th scope="col">Loaf</th>
      <th scope="col">September</th>
      <th scope="col">October</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Sourdough</th>
      <td>412</td>
      <td>455</td>
    </tr>
  </tbody>
</table>

Only a handful of CSS properties work on columns: background, border, width and visibility. For other styling, target the cells.

The headers attribute

For irregular tables where scope cannot express the relationships, a td can list the id values of its header cells:

HTML
<table>
  <caption>Saturday classes</caption>
  <tr>
    <th id="sat" colspan="2">Saturday</th>
  </tr>
  <tr>
    <th id="am">Morning</th>
    <th id="pm">Afternoon</th>
  </tr>
  <tr>
    <td headers="sat am">Basics</td>
    <td headers="sat pm">Advanced</td>
  </tr>
</table>

It works, but it is laborious and easy to break when the table changes. If you find yourself needing headers throughout a table, the table is probably too complex.

When to split a table

Complex tables are hard for everyone, not just screen reader users. Before reaching for multi-level headers, ask whether two or three simpler tables, each with its own caption, would be clearer. They often are, and they are much easier to make responsive.

Large tables are difficult on small screens. The Responsive Web Design course covers techniques such as scrolling containers and alternative layouts for narrow viewports.

Summary

  • Give every data table a caption as its first child.
  • Add scope="col" or scope="row" to every th, and colgroup or rowgroup for grouped headers.
  • Use colspan and rowspan for merged cells, and make every row add up to the same number of columns.
  • Use the headers attribute only for genuinely irregular tables.
  • If a table needs many levels of headers, consider splitting it.