Tables and Structured Data
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.
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 thealttext for an image.thead— the header row(s). Contains column labels.tbody— the data rows.tfoot— footer rows (totals, summaries).th— header cell. Usesscope="col"for column headers andscope="row"for row headers.td— data cell.tr— table row.
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.
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 columnsrowspan="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 table | Use CSS Grid |
|---|---|
| Data has meaningful row/column relationships | Layout where position is visual, not semantic |
| Users need to compare values across rows/columns | Cards, galleries, page layouts |
| Screen readers should announce row and column headers | Content where cell relationships don't matter |
| Comparison charts, financial data, schedules | Navigation, 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>
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 do | What 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:
captiondescribes 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
timeelement for machine-readability - Data in
tbodyseparated from headers inthead - A screen reader navigating to "Brendan Eich" announces: "Creator, 1, Brendan Eich"
- 1Use tables only for tabular data — never for page layout
- 2Always include caption for the table's accessible name
- 3Use th with scope='col' for column headers and scope='row' for row headers
- 4Wrap wide tables in a scrollable container with role='region' and aria-label for mobile accessibility
- 5If removing the row/column structure destroys the meaning of the data, use a table — otherwise use CSS Grid