HTML Table Headers

A table is only as useful as its labels. The <th> element marks a cell as a header cell — a label that describes the data in a row or column — rather than an ordinary data cell. Table headers aren’t just a visual convenience; they give the table structure and meaning, which matters for sighted readers scanning a grid and is essential for screen reader users who rely on headers to understand what each cell means.

This lesson covers how <th> works, how to mark column headers versus row headers with the scope attribute, how headers interact with <thead>, and how to handle headers in complex tables with merged cells.

Overview / How it works

Inside a <table>, every visible cell is either a data cell (<td>) or a header cell (<th>). Both live inside a <tr> (table row), and both are valid children of any row — the browser doesn’t require headers to appear only in a special row. What makes <th> different is semantic: it tells the browser, and any assistive technology, “this cell is a label for other cells,” not “this cell is a value.”

By default, browsers render <th> content bold and centered, while <td> content is normal weight and left-aligned. This is just the user-agent stylesheet — visual styling belongs to CSS, and you can (and in real projects, will) override it. But don’t reach for <th> because you want bold text; that’s what CSS font-weight is for. Reach for <th> because the cell’s content genuinely labels a row or column of data.

In the DOM, a <th> element has the same structural role as a <td> — it’s a table cell node inside a row — but it also carries accessibility semantics exposed through the accessibility tree as a “columnheader” or “rowheader” role, depending on context and the scope attribute. Screen readers use this to announce, for example, “Price, column header” when a user navigates into a data cell, then read the associated header before the value: “Price: $42.”

Syntax

<table>
  <tr>
    <th scope="col">Header text</th>
  </tr>
  <tr>
    <td>Data</td>
  </tr>
</table>
  • <th> — defines a header cell; can appear in any row, any position.
  • scope — tells the browser/assistive tech whether the header applies to a column, a row, a group of columns (colgroup), or a group of rows (rowgroup).
  • colspan — (same attribute as on <td>) makes a header span multiple columns, common for grouped headers.
  • rowspan — makes a header span multiple rows.
  • id / headers — an alternative labeling mechanism: give each <th> an id, then reference it from a <td>‘s headers attribute (space-separated list) for tables too complex for scope alone.

Examples

Example 1: A simple header row

<table>
  <tr>
    <th>Name</th>
    <th>Age</th>
    <th>City</th>
  </tr>
  <tr>
    <td>Amara</td>
    <td>29</td>
    <td>Lagos</td>
  </tr>
  <tr>
    <td>Kenji</td>
    <td>34</td>
    <td>Osaka</td>
  </tr>
</table>

Result: A 3-column, 3-row grid. The first row (“Name”, “Age”, “City”) renders bold and centered by default, visually distinct from the two data rows beneath it, which are left-aligned and normal weight.

Even without a <thead> wrapper or a scope attribute, using <th> here already communicates to assistive technology that these three cells are column labels for the rows that follow.

Example 2: Explicit scope with thead/tbody

<table>
  <thead>
    <tr>
      <th scope="col">Product</th>
      <th scope="col">Price</th>
      <th scope="col">In Stock</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Notebook</th>
      <td>$4.50</td>
      <td>Yes</td>
    </tr>
    <tr>
      <th scope="row">Stapler</th>
      <td>$9.00</td>
      <td>No</td>
    </tr>
  </tbody>
</table>

Result: A 3-column table where the top row (“Product”, “Price”, “In Stock”) is bold as a column header band, and the first cell of each subsequent row (“Notebook”, “Stapler”) is also bold, acting as a row label. Visually it looks like a spreadsheet with both a header row and a header column.

This is the most common real-world pattern: scope="col" marks the top row as labeling the columns beneath it, and scope="row" marks the leftmost cell as labeling the rest of that row. A screen reader announcing the “$9.00” cell will say something like “Price: $9.00, Stapler” — both header dimensions are announced automatically.

Example 3: Grouped headers with colspan and the headers attribute

<table>
  <thead>
    <tr>
      <th rowspan="2" scope="col">Student</th>
      <th colspan="2" scope="colgroup">Term 1</th>
      <th colspan="2" scope="colgroup">Term 2</th>
    </tr>
    <tr>
      <th id="t1-math" scope="col">Math</th>
      <th id="t1-sci" scope="col">Science</th>
      <th id="t2-math" scope="col">Math</th>
      <th id="t2-sci" scope="col">Science</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Priya</th>
      <td headers="t1-math">88</td>
      <td headers="t1-sci">91</td>
      <td headers="t2-math">93</td>
      <td headers="t2-sci">89</td>
    </tr>
  </tbody>
</table>

Result: A grid with two header rows: the top row shows “Student” (spanning two rows tall) plus “Term 1” and “Term 2” each spanning two columns wide; the second row shows “Math” and “Science” under each term. Below that, one data row for Priya shows four scores aligned under the correct term/subject pair.

This is a genuinely complex header structure — a single data cell like “93” has two headers above it (“Term 2” and “Math”), plus a row header (“Priya”). scope alone can’t express that a cell relates to two stacked header levels, so each subject header gets a unique id, and each data cell lists the relevant ids in its headers attribute. Screen readers read all referenced headers before the value.

How it works step by step

When the browser parses a table, it builds an anonymous table box model: rows become horizontal bands, and cells (whether <th> or <td>) become grid cells positioned by their order and any colspan/rowspan. The parser doesn’t care whether a cell is a header when computing layout — column widths and row heights are calculated the same way regardless.

What differs is the accessibility tree construction. As the browser builds it, a <th> is exposed with a header role. If it has scope="col", assistive tech associates it with every cell in that column until the next column header appears. With scope="row", it associates with the rest of that row. If a cell has an explicit headers attribute, the browser overrides the automatic scope-based association and uses exactly the ids listed — this is why headers/id is the fallback for tables where automatic association would be ambiguous.

Common Mistakes

Mistake 1: Using bold <td> instead of <th> for labels. It’s tempting to fake a header visually:

<tr>
  <td><strong>Name</strong></td>
  <td><strong>Age</strong></td>
</tr>

This renders bold, so it looks right, but the browser and any assistive technology still see two ordinary data cells with no relationship to the rows below. A screen reader user gets no announcement of what “29” or “Lagos” mean. Fix it by using real header cells:

<tr>
  <th scope="col">Name</th>
  <th scope="col">Age</th>
</tr>

Mistake 2: Leaving a header cell unclosed, breaking every cell after it.

<tr>
  <th>Name
  <th>Age</th>
</tr>

Omitting </th> is technically tolerated by HTML’s error-recovery parsing (an unclosed <th> auto-closes when the next <th>, <td>, or </tr> is seen), but relying on this makes markup fragile and confusing to read, and validators will flag it. Always close your header cells explicitly:

<tr>
  <th>Name</th>
  <th>Age</th>
</tr>

Best Practices

  • Use <th> for every cell that labels a row or column of data — reserve <td> strictly for values.
  • Add scope="col" or scope="row" on every <th> so assistive technology knows which direction the header applies.
  • Wrap your header row in <thead> and the data rows in <tbody> — this groups headers semantically and lets browsers repeat them on printed page breaks.
  • For tables with two-level or irregular header structures, use unique ids on each <th> and reference them from data cells with headers, rather than relying on scope to guess the relationship.
  • Never use <th> purely to make text bold; use CSS for visual weight and reserve <th> for genuine header semantics.
  • Keep header text concise — long header labels make every cell in that column visually wide and repetitive for screen reader users navigating cell by cell.

Practice Exercises

1. Build a 4-column, 3-row table listing three books (title, author, year, genre) with a proper header row using <th scope="col">.

2. Extend your table from exercise 1 so the leftmost cell of each data row (the title) also becomes a <th scope="row">, turning it into a table with both column and row headers.

3. Design a table for a weekly workout schedule where the header row spans two levels: a top row grouping “Morning” and “Evening”, and a second row listing days of the week under each. Use colspan, rowspan, and id/headers to correctly associate each cell.

Summary

  • <th> marks a header cell — a semantic label, not just bold text.
  • By default, header cells render bold and centered, but that’s presentation, not the reason to use them.
  • scope="col" and scope="row" tell assistive technology which cells a header describes.
  • <thead> groups header rows separately from <tbody>‘s data rows.
  • For complex, multi-level headers, use unique ids on <th> elements and reference them with the headers attribute on data cells.
  • Always close header cells explicitly and never substitute bold <td> content for real <th> semantics.