Tables

Mark up genuinely tabular data using <table>, <tr>, <th>, <td>, structural grouping elements, spanning cells, and accessibility attributes — and understand why tables should never be used for page layout.

Tables exist to present data that has rows and columns — a spec sheet, a schedule, a comparison chart, a set of diagnostic readings. HTML tables have a specific, structured vocabulary of elements, and using them correctly (including for accessibility) is different from just making something visually look grid-like.

Core Table Elements

<table> is the container for the whole table. Inside it, each row is a <tr> (table row). Inside each row, individual cells are either <th> (table header — for a cell that labels a row or column) or <td> (table data — for an ordinary data cell).

Using <th> instead of <td> for header cells isn't just about bold, centered default styling — it's semantic, and screen readers use it to announce which row/column header applies to a given data cell as a user navigates the table.

Structuring with thead, tbody, tfoot

For anything beyond a trivial table, wrap the header row(s) in <thead>, the main data rows in <tbody>, and any summary row (totals, averages) in <tfoot>. This isn't just organizational — browsers can use it for things like keeping the header visible while the body scrolls, and it makes the table's structure clearer to both other developers and assistive technology.

A table can have only one <thead> and one <tfoot>, but can have multiple <tbody> elements if you want to visually or logically group rows within the same table.

Captions

<caption> provides a title for the entire table, placed as the very first child inside <table> (before <thead>). It's announced by screen readers before the table's content, giving context about what the table represents — similar in spirit to <figcaption> for images, but specific to tables.

Spanning Cells: colspan and rowspan

Sometimes a single cell needs to stretch across multiple columns or rows — a section header spanning an entire row's width, or a value that applies to two consecutive rows. colspan="2" on a cell makes it occupy two columns' worth of space; rowspan="2" makes it occupy two rows' worth. Both accept any number, and when a cell spans, the rows after it simply have one fewer cell to account for the space already claimed.

Accessibility: the scope Attribute

On a <th>, the scope attribute explicitly states whether that header applies to its row or its column: scope="col" for a column header, scope="row" for a row header. Without it, a screen reader has to guess the association from table structure, which works for simple tables but gets ambiguous fast in anything with spanning cells or multiple header levels.

Adding scope takes one attribute and meaningfully improves how well a table's data is understood by someone navigating it non-visually — genuinely tabular data, especially data-heavy technical tables, should always include it.

The Classic Mistake: Tables for Layout

In the early days of the web, before CSS layout tools were mature, developers commonly abused <table> to arrange entire page layouts — sidebar in one cell, content in another, footer spanning the bottom row. This is now considered a serious anti-pattern.

Tables-for-layout breaks accessibility (screen readers announce layout tables as actual data tables, confusing users with irrelevant 'row 1 of 4, column 2 of 3' announcements for what is really just a sidebar), breaks responsive design (table layouts don't reflow naturally on small screens), and mixes presentation with structure in a way that makes a page brittle to redesign. Modern CSS (Flexbox, Grid) handles layout; <table> is reserved exclusively for actual tabular data.

Table Skeleton

<caption> comes first, immediately inside <table> <thead> holds header rows, <tbody> holds data rows scope="col" on each <th> clarifies it labels the column beneath it

<table>
  <caption>Vehicle Data</caption>
  <thead>
    <tr>
      <th scope="col">ECU</th>
      <th scope="col">Status</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ABS</td>
      <td>OK</td>
    </tr>
  </tbody>
</table>

Code Examples

thead holds the column headers, tbody holds the two readings, and tfoot holds a summary row where colspan="2" merges the first two cells so the label 'Average Voltage' spans both, aligning the average value under the Voltage column.

rowspan="2" on the 'Morning' cell makes it span both rows, since two technicians share that same morning shift. Note the second <tr> only contains one <td> — the shift cell's space was already claimed by the row above.

<td rowspan="2">Morning</td> Column headers use scope="col" and the row-labeling color names use scope="row" as <th> cells — this makes each data cell's association with both its row and column header explicit for assistive technology.

<th scope="row">Black</th> This is not tabular data — it's a two-column page layout misusing <table>. A screen reader will announce this as a data table with irrelevant row/column context. Use CSS Grid or Flexbox for layout instead.

Common mistake: Tables should hold data with real row/column relationships, never be used to visually position unrelated page sections.

<table>
  <caption>Vehicle Diagnostic Data</caption>
  <thead>
    <tr>
      <th scope="col">ECU</th>
      <th scope="col">Status</th>
      <th scope="col">Voltage</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td>ABS</td>
      <td>OK</td>
      <td>12.6V</td>
    </tr>
    <tr>
      <td>Engine</td>
      <td>Warning</td>
      <td>11.9V</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <td colspan="2">Average Voltage</td>
      <td>12.25V</td>
    </tr>
  </tfoot>
</table>
<table>
  <caption>Lab Shift Schedule</caption>
  <thead>
    <tr>
      <th scope="col">Shift</th>
      <th scope="col">Technician</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <td rowspan="2">Morning</td>
      <td>Alicia Chen</td>
    </tr>
    <tr>
      <td>Deepa Rao</td>
    </tr>
  </tbody>
</table>
<table>
  <caption>Resistor Color Code (Selected Values)</caption>
  <thead>
    <tr>
      <th scope="col">Color</th>
      <th scope="col">Digit</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Black</th>
      <td>0</td>
    </tr>
    <tr>
      <th scope="row">Brown</th>
      <td>1</td>
    </tr>
  </tbody>
</table>
<!-- WRONG: using a table to lay out a page -->
<table>
  <tr>
    <td width="200">Sidebar navigation...</td>
    <td>Main article content...</td>
  </tr>
</table>

Table Structure

caption, thead, tbody, and tfoot each play a distinct structural role inside table.

<table>
  <caption>  ... title for the whole table
  <thead>
    <tr><th>col</th><th>col</th></tr>
  <tbody>
    <tr><td>data</td><td>data</td></tr>
    <tr><td>data</td><td>data</td></tr>
  <tfoot>
    <tr><td>summary row</td></tr>

Important Rules

Use <th> for header cells and <td> for data cells — never use <td> styled to look bold as a substitute for <th>.

Group rows with <thead>/<tbody>/<tfoot> for anything beyond a trivial table.

Place <caption> as the first child of <table>.

Add scope="col" or scope="row" to every <th> for accessibility.

Never use <table> to build page layout — use CSS Grid or Flexbox instead.

Where This Shows Up

Pricing comparison pages, sports statistics, financial reports, and engineering spec sheets are all genuinely tabular data that belongs in a real <table> — while the 'tables for layout' mistake was common in web design through the early 2000s and is now considered a textbook accessibility and maintainability failure.

<table> <tr> <td><b>Name</b></td> <td><b>Score</b></td> </tr> <tr> <td>Alex</td> <td>92</td> </tr> </table>

<table> <thead> <tr> <th scope="col">Name</th> <th scope="col">Score</th> </tr> </thead> <tbody> <tr> <td>Alex</td> <td>92</td> </tr> </tbody> </table>

Bold <td> cells might look like headers visually, but they carry none of the semantic or accessibility benefit of actual <th> elements with scope.

<table> <tr><td colspan="3">Header spanning row</td></tr> <tr><td>A</td><td>B</td><td>C</td><td>D</td></tr> </table>

<table> <tr><td colspan="4">Header spanning row</td></tr> <tr><td>A</td><td>B</td><td>C</td><td>D</td></tr> </table> colspan="3" only accounts for 3 columns, but the row beneath it has 4 cells — the colspan value must match the actual number of columns it's meant to span, or the table's columns will misalign.

[True / False] True or False: Because CSS can make a <table> look like anything, including a full page layout, it is an acceptable modern technique to build a page's sidebar-plus-content layout using <table> and <td> instead of CSS Grid or Flexbox. A. True B. False

B. False — False. Even though a table can be styled to look like anything visually, using <table> for layout is a well-known anti-pattern: it confuses screen readers (which announce it as a real data table), doesn't reflow responsively the way CSS Grid/Flexbox layouts do, and mixes structure with presentation. Tables should be reserved for genuinely tabular data.

Build a table titled 'Sensor Readings' with columns Sensor and Reading (using scope on headers), two data rows (Temperature: 22C, Humidity: 45%), and a footer row where a single cell spans both columns reading 'Last updated: 09:00'.

<table> <caption>Sensor Readings</caption> <thead> <tr> <th scope="col">Sensor</th> <th scope="col">Reading</th> </tr> </thead> <tbody> <tr><td>Temperature</td><td>22C</td></tr> <tr><td>Humidity</td><td>45%</td></tr> </tbody> <tfoot> <tr><td colspan="2">Last updated: 09:00</td></tr> </tfoot> </table> caption first, then thead with scope="col" headers, then tbody with two rows, then tfoot with one row containing a colspan="2" cell.