Skip to content

Tables and Structured Data

beginner12 min read

The Most Misused Element in HTML History

In the early 2000s, entire websites were built with tables. Navigation? Table. Sidebar? Table. Footer? Table inside a table inside a table. It was a dark time.

Then CSS layout arrived, and the pendulum swung hard the other way. "Never use tables!" became the battle cry. Developers started building actual data tables with divs and CSS Grid instead.

Both extremes are wrong. Tables have one correct use: displaying tabular data. If your content has rows and columns where the relationships between cells matter — comparison charts, financial data, schedules, statistics — a table is the only correct element. Anything else hurts accessibility.

Mental Model

Think of an HTML table like a spreadsheet. Each cell has a row and a column, and the intersection of row and column gives the cell meaning. "Q1 Revenue: $2.4M" only makes sense because the column header says "Q1" and the row header says "Revenue." Without those headers, $2.4M is just a random number. HTML table structure exists to preserve these relationships — especially for screen readers that announce "Row 3, Column 2: $2.4M, Q1, Revenue."

Table Structure

A well-structured table has three sections:

<table>
  <caption>Quarterly Revenue by Department</caption>
  <thead>
    <tr>
      <th scope="col">Department</th>
      <th scope="col">Q1</th>
      <th scope="col">Q2</th>
      <th scope="col">Q3</th>
      <th scope="col">Q4</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Engineering</th>
      <td>$1.2M</td>
      <td>$1.4M</td>
      <td>$1.5M</td>
      <td>$1.8M</td>
    </tr>
    <tr>
      <th scope="row">Marketing</th>
      <td>$800K</td>
      <td>$750K</td>
      <td>$900K</td>
      <td>$1.1M</td>
    </tr>
  </tbody>
  <tfoot>
    <tr>
      <th scope="row">Total</th>
      <td>$2.0M</td>
      <td>$2.15M</td>
      <td>$2.4M</td>
      <td>$2.9M</td>
    </tr>
  </tfoot>
</table>

Let's break this down:

  • caption — the table's title. Screen readers announce it when entering the table. It's like the alt text for an image.
  • thead — the header row(s). Contains column labels.
  • tbody — the data rows.
  • tfoot — footer rows (totals, summaries).
  • th — header cell. Uses scope="col" for column headers and scope="row" for row headers.
  • td — data cell.
  • tr — table row.
Quiz
What is the purpose of the scope attribute on th elements?

Why Structure Matters for Accessibility

A screen reader navigates a table cell by cell. For each cell, it announces the associated row and column headers. Without proper th and scope attributes, the reader can't make these associations.

Poorly structured table:

<table>
  <tr>
    <td><strong>Name</strong></td>
    <td><strong>Role</strong></td>
    <td><strong>Salary</strong></td>
  </tr>
  <tr>
    <td>Alice</td>
    <td>Engineer</td>
    <td>$120K</td>
  </tr>
</table>

Screen reader on "$120K": just announces "$120K" — no context.

Well-structured table:

<table>
  <caption>Team Salaries</caption>
  <thead>
    <tr>
      <th scope="col">Name</th>
      <th scope="col">Role</th>
      <th scope="col">Salary</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Alice</th>
      <td>Engineer</td>
      <td>$120K</td>
    </tr>
  </tbody>
</table>

Screen reader on "$120K": announces "Salary, Alice, $120K" — the cell makes sense in context.

Execution Trace
Enter table
Screen reader: 'Table: Team Salaries, 2 rows, 3 columns'
Caption provides the table name and screen reader counts dimensions
Navigate to data cell
User moves to row 2, column 3
Arrow keys move between cells in the table
Announce cell
Screen reader: 'Salary, Alice, $120K'
Column header (Salary) and row header (Alice) are announced with the cell value
Move right
Screen reader: 'No more columns'
User knows they've reached the end of the row
Quiz
Why should you use th with scope="row" for the first cell in each data row?

Spanning Rows and Columns

Sometimes you need cells that span multiple columns or rows:

<table>
  <caption>Course Schedule</caption>
  <thead>
    <tr>
      <th scope="col">Time</th>
      <th scope="col">Monday</th>
      <th scope="col">Tuesday</th>
      <th scope="col">Wednesday</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">9:00 AM</th>
      <td colspan="2">HTML Workshop (2 days)</td>
      <td>CSS Basics</td>
    </tr>
    <tr>
      <th scope="row">2:00 PM</th>
      <td>JavaScript</td>
      <td>JavaScript</td>
      <td rowspan="2">Project Lab (extended session)</td>
    </tr>
    <tr>
      <th scope="row">4:00 PM</th>
      <td>Review</td>
      <td>Review</td>
    </tr>
  </tbody>
</table>
  • colspan="2" — the cell spans 2 columns
  • rowspan="2" — the cell spans 2 rows

Use spanning sparingly. Complex tables with many spans become very difficult for screen readers to navigate.

Tables vs. CSS Grid: When to Use Each

Use a tableUse CSS Grid
Data has meaningful row/column relationshipsLayout where position is visual, not semantic
Users need to compare values across rows/columnsCards, galleries, page layouts
Screen readers should announce row and column headersContent where cell relationships don't matter
Comparison charts, financial data, schedulesNavigation, feature lists, pricing cards

The rule: if removing the row/column structure destroys the meaning, use a table. If the data still makes sense as a list or cards, use CSS Grid.

<!-- This is tabular data — use a table -->
<table>
  <caption>Browser Support</caption>
  <thead>
    <tr>
      <th scope="col">Feature</th>
      <th scope="col">Chrome</th>
      <th scope="col">Firefox</th>
      <th scope="col">Safari</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">Container Queries</th>
      <td>105+</td>
      <td>110+</td>
      <td>16+</td>
    </tr>
  </tbody>
</table>

<!-- This is NOT tabular data — use a list or grid -->
<!-- A list of team members is NOT a table -->
<ul class="team-grid">
  <li>
    <img src="alice.jpg" alt="">
    <h3>Alice</h3>
    <p>Engineer</p>
  </li>
</ul>
Quiz
A pricing page shows three plans (Free, Pro, Team) with features like 'Storage', 'Support', 'Users'. Is this tabular data?

Production Scenario: Responsive Data Table

Tables on mobile are tricky — wide tables overflow small screens. Here's a production pattern:

<div class="table-container" role="region" aria-label="Course pricing" tabindex="0">
  <table>
    <caption>Feature Comparison</caption>
    <thead>
      <tr>
        <th scope="col">Feature</th>
        <th scope="col">Free</th>
        <th scope="col">Pro</th>
        <th scope="col">Team</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Courses</th>
        <td>5</td>
        <td>Unlimited</td>
        <td>Unlimited</td>
      </tr>
      <tr>
        <th scope="row">Certificates</th>
        <td>No</td>
        <td>Yes</td>
        <td>Yes</td>
      </tr>
      <tr>
        <th scope="row">Support</th>
        <td>Community</td>
        <td>Priority Email</td>
        <td>Dedicated Slack</td>
      </tr>
    </tbody>
  </table>
</div>

The wrapper div with overflow-x: auto allows horizontal scrolling on mobile. The role="region", aria-label, and tabindex="0" make the scrollable area keyboard-accessible — users can tab to it and scroll with arrow keys.

.table-container {
  overflow-x: auto;
  -webkit-overflow-scrolling: touch;
}

.table-container:focus {
  outline: 2px solid var(--color-accent);
  border-radius: 4px;
}
What developers doWhat they should do
Using tables for page layout (sidebars, headers, grids)
Layout tables break screen reader navigation — the reader announces rows and columns for content that has no tabular relationship. It's the #1 accessibility violation from the early web
Use CSS Flexbox or Grid for layout. Tables are for tabular data only
Using td elements for header cells
Screen readers can't identify headers if they're td elements with bold styling. th with scope creates the programmatic association that assistive technology needs
Use th with appropriate scope for all row and column headers
Omitting the caption element
The caption is the table's accessible name. Screen readers announce it when entering the table, giving users context before they navigate the data
Always include a caption describing what the table contains
Building data tables with div elements and CSS Grid
CSS Grid div structures have no semantic table relationships. Screen readers can't navigate them as tables or announce associated headers
If the data has meaningful row/column relationships, use a real table element

Challenge: Build an Accessible Data Table

Create a table showing the top 3 programming languages with columns for: Language, Year Created, Creator, and Popularity (%). Include proper headers, caption, and structure.

Show Answer
<table>
  <caption>Top Programming Languages by Popularity (2024)</caption>
  <thead>
    <tr>
      <th scope="col">Rank</th>
      <th scope="col">Language</th>
      <th scope="col">Year Created</th>
      <th scope="col">Creator</th>
      <th scope="col">Popularity</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th scope="row">1</th>
      <td>JavaScript</td>
      <td><time datetime="1995">1995</time></td>
      <td>Brendan Eich</td>
      <td>65%</td>
    </tr>
    <tr>
      <th scope="row">2</th>
      <td>Python</td>
      <td><time datetime="1991">1991</time></td>
      <td>Guido van Rossum</td>
      <td>48%</td>
    </tr>
    <tr>
      <th scope="row">3</th>
      <td>TypeScript</td>
      <td><time datetime="2012">2012</time></td>
      <td>Anders Hejlsberg</td>
      <td>35%</td>
    </tr>
  </tbody>
</table>

Key points:

  • caption describes the table and its time context
  • Column headers use th scope="col"
  • Row headers (rank) use th scope="row" — gives each row an identifier
  • Years wrapped in time element for machine-readability
  • Data in tbody separated from headers in thead
  • A screen reader navigating to "Brendan Eich" announces: "Creator, 1, Brendan Eich"
Key Rules
  1. 1Use tables only for tabular data — never for page layout
  2. 2Always include caption for the table's accessible name
  3. 3Use th with scope='col' for column headers and scope='row' for row headers
  4. 4Wrap wide tables in a scrollable container with role='region' and aria-label for mobile accessibility
  5. 5If removing the row/column structure destroys the meaning of the data, use a table — otherwise use CSS Grid