Tables — Structure and Correct Usage
table, thead/tbody/tfoot, th, td, colspan/rowspan — and exactly why tables should never be used for page layout.
The Four Elements Every Table Is Built From
A table is built from a small, strict set of elements, each with one job: <table> wraps the whole thing, <tr> (table row) defines one row, and each cell inside a row is either <td> (table data — a regular cell) or <th> (table header — a cell that labels a row or column, not just data).
<table>
<tr>
<th>Product</th>
<th>Price</th>
<th>In Stock</th>
</tr>
<tr>
<td>Wireless Mouse</td>
<td>$24.99</td>
<td>Yes</td>
</tr>
<tr>
<td>USB-C Hub</td>
<td>$39.99</td>
<td>No</td>
</tr>
</table>The distinction between <th> and <td> is not cosmetic — browsers apply bold, centered default styling to <th>, but the real reason it exists is semantic: it marks a cell as a header for the data around it, information screen readers use to announce which column or row a given cell belongs to as a user navigates the table cell by cell. Using <td> everywhere and faking bold headers with CSS throws away that relationship entirely, even though the table looks identical to a sighted user.
<table>. Non-tabular content laid out to visually resemble columns is a different problem, covered fully in Part 07.Grouping Rows Into Head, Body, and Foot
A real table almost always separates its header row from its data rows structurally, not just visually. <thead> wraps the header row(s), <tbody> wraps the actual data rows, and an optional <tfoot> wraps summary or total rows that belong at the bottom.
<table>
<thead>
<tr>
<th>Product</th>
<th>Price</th>
<th>Quantity</th>
</tr>
</thead>
<tbody>
<tr>
<td>Wireless Mouse</td>
<td>$24.99</td>
<td>2</td>
</tr>
<tr>
<td>USB-C Hub</td>
<td>$39.99</td>
<td>1</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="2">Total</td>
<td>$89.97</td>
</tr>
</tfoot>
</table>This grouping is not just organizational tidiness. Browsers use it to enable independent scrolling of a long table's body while keeping the header pinned in view, CSS can target tbody tr to style only data rows without touching the header, and assistive technology uses the structural separation to distinguish "this is a label" from "this is the data the label describes" more reliably than styling alone ever could.
Multiple tbody elements — grouping related rows within one table
A single table can contain more than one <tbody>, useful for visually and semantically grouping subsets of rows — a sales report broken into regions, for example — without breaking the table into several separate, disconnected tables.
<table>
<thead>
<tr><th>Region</th><th>Rep</th><th>Revenue</th></tr>
</thead>
<tbody>
<tr><td>West</td><td>Dana Lee</td><td>$142,000</td></tr>
<tr><td>West</td><td>Omar Reyes</td><td>$98,500</td></tr>
</tbody>
<tbody>
<tr><td>East</td><td>Priya Nair</td><td>$167,200</td></tr>
</tbody>
</table>scope — Telling a Screen Reader What a Header Cell Actually Labels
A <th> marks a cell as a header, but in a table with headers running both across the top and down the left side, a screen reader cannot always infer on its own whether a given <th> labels the column beneath it or the row beside it. The scope attribute removes that ambiguity explicitly.
<table>
<thead>
<tr>
<th scope="col">Employee</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Dana Lee</th>
<td>142</td>
<td>158</td>
</tr>
<tr>
<th scope="row">Omar Reyes</th>
<td>98</td>
<td>112</td>
</tr>
</tbody>
</table>With scope in place, a screen reader user landing on the cell containing 158 hears something equivalent to "Q2, Dana Lee: 158" — both the column header and the row header, resolved unambiguously. Without it, in a table with headers on two sides, that same cell might be announced with no header context at all, leaving the listener to manually count rows and columns from the start of the table to figure out what a number even represents.
<th> starting each row, not just each column) needs scope="row" on those cells. This single attribute is one of the highest-impact, lowest-effort accessibility fixes for real data tables, and it is skipped constantly — most developers remember <th> for the top header row and forget it applies to row labels too.colspan and rowspan — Making a Cell Span Multiple Columns or Rows
colspan makes a single cell stretch across multiple columns; rowspan makes it stretch down across multiple rows. Both take a number — how many columns or rows the cell should occupy — and both directly affect how many <td>/<th> elements the surrounding rows need, since a spanned cell effectively "uses up" cells that would otherwise need to be written explicitly in the row(s) it spans into.
<table>
<tbody>
<tr>
<td>Wireless Mouse</td>
<td>2 × $24.99</td>
<td>$49.98</td>
</tr>
<tr>
<td colspan="2">Total</td>
<td>$49.98</td>
</tr>
</tbody>
</table>
<!-- The totals row only needs 2 td elements, not 3 — the first
td's colspan="2" already covers the space of two columns. --><table>
<tbody>
<tr>
<th rowspan="2">Engineering</th>
<td>Dana Lee</td>
</tr>
<tr>
<td>Omar Reyes</td>
</tr>
<tr>
<th>Design</th>
<td>Priya Nair</td>
</tr>
</tbody>
</table>
<!-- The second row has no th of its own — the first row's th
rowspan="2" already occupies that cell's position. -->colspan/rowspan cell removes the need to explicitly write the cells it covers, and accidentally writing them anyway, which pushes every real cell one position further right or down than intended.caption — Giving a Table an Accessible Title
<caption> provides a title for the entire table, and — much like <figcaption> for a <figure> — it must be the first child immediately inside <table> to be valid. Unlike a heading placed above a table, it is programmatically tied to that specific table, so a screen reader announces it the moment a user enters the table, before hearing any header or data cells.
<table>
<caption>Q1 2026 Sales by Region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Revenue</th>
</tr>
</thead>
<tbody>
<tr>
<td>West</td>
<td>$240,500</td>
</tr>
<tr>
<td>East</td>
<td>$167,200</td>
</tr>
</tbody>
</table>A common alternative — an <h2> or <h3> placed directly above the table — is visually indistinguishable but structurally weaker: nothing in the markup formally connects that heading to the specific table beneath it, especially once other content sits between them, or if the table is reordered by responsive CSS. <caption> removes that ambiguity entirely, at the cost of being slightly less common to see in real production code than it should be.
A Complete, Properly Structured Table
Every element covered so far combines into one real, correctly built table — the shape you should be aiming for whenever you are marking up genuine tabular data in production code.
<table>
<caption>Employee Directory — Engineering Team</caption>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Title</th>
<th scope="col">Start Date</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Dana Lee</th>
<td>Senior Engineer</td>
<td>March 2023</td>
</tr>
<tr>
<th scope="row">Omar Reyes</th>
<td>Staff Engineer</td>
<td>June 2021</td>
</tr>
</tbody>
<tfoot>
<tr>
<td colspan="3">2 employees shown</td>
</tr>
</tfoot>
</table>Notice what each piece contributes: <caption> names the table, <thead>/<tbody>/<tfoot> separate its structural regions, scope disambiguates header direction on both axes, and every cell is exactly the right element for what it contains — data in <td>, labels in <th>. This is the level of structure real production data tables should carry, even though a large amount of code in the wild skips most of it and relies on default rendering to look "close enough."
Why Tables Were Once Used for Page Layout — And Exactly Why That Stopped
Long before Flexbox or Grid existed, CSS had no reliable way to build a multi-column page layout — no way to put a sidebar next to a main content area, or arrange a header, nav, content, and footer into a real page skeleton, that worked consistently across browsers. Tables, however, were already excellent at exactly one thing CSS could not yet do: reliably arranging content into rows and columns. So throughout the late 1990s and much of the 2000s, an entire generation of websites was built by wrapping the entire page in nested <table> elements — a header row, a row with two or three <td> "columns" acting as a sidebar and main content area, a footer row — using table structure purely to achieve a visual arrangement that had nothing to do with tabular data.
<table width="960">
<tr>
<td colspan="2">
<!-- site header/logo -->
</td>
</tr>
<tr>
<td width="200">
<!-- sidebar navigation -->
</td>
<td>
<!-- main page content -->
</td>
</tr>
<tr>
<td colspan="2">
<!-- footer -->
</td>
</tr>
</table>
<!-- None of this is real tabular data. It's page structure, forced
into table syntax because CSS layout wasn't reliable enough yet. -->This approach worked, in the narrow sense that it rendered a multi-column page. But it caused real, serious, compounding problems that got worse as the web grew: a screen reader encountering this markup announces it as a table of data, with row and column navigation commands, forcing a blind user to navigate an entire page as if it were a spreadsheet full of nonsense cells. The markup carried zero semantic meaning about what the content actually was — a nav, a header, an article — because everything was just generic table cells. Nested tables (a table inside a table inside a table, which real layouts frequently required) were slow for browsers to calculate and render, since a table's column widths cannot be finalized until its entire content has been parsed. And restructuring the page for a different screen size meant physically rewriting the table structure itself, since tables have no concept of responsively reflowing content the way modern layout systems do.
Knowing this history is not just trivia. It explains a real, still-visible pattern: any time you encounter a table where the cells hold layout regions (a sidebar, a header, a footer) rather than actual data values, that is a signal of legacy code built under real technical constraints that no longer exist — and a strong candidate for a rewrite using semantic elements (covered in the Text Elements & Semantic Structure module) combined with Flexbox or Grid, once you reach those modules.
A Fintech Company in Charlotte Rebuilds Its Transaction History Table
A banking app undergoing a third-party accessibility audit ahead of a compliance deadline gets flagged specifically on its transaction history page — a table with dates down the left side and account types across the top, showing balances at each intersection.
<table>
<tr>
<td></td>
<td>Checking</td>
<td>Savings</td>
</tr>
<tr>
<td>Jan 2026</td>
<td>$4,210.55</td>
<td>$12,800.00</td>
</tr>
<tr>
<td>Feb 2026</td>
<td>$3,940.10</td>
<td>$13,100.00</td>
</tr>
</table>What the audit report says
The report lists three separate findings, all traceable to specific parts of this module. First: no <th> elements anywhere — every cell, including the row and column labels, is a plain <td>, so nothing marks "Checking," "Savings," or the month labels as headers at all. Second: no scope attributes, meaning even if <th> were added, a screen reader would have no way to tell whether a given header applies to its column or its row — critical in a table with headers on both axes, like this one. Third: no <caption>, so a screen reader user landing on the table has no announced title describing what it actually contains before navigating into it.
<table>
<caption>Account balances by month, Checking and Savings</caption>
<thead>
<tr>
<th scope="col">Month</th>
<th scope="col">Checking</th>
<th scope="col">Savings</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Jan 2026</th>
<td>$4,210.55</td>
<td>$12,800.00</td>
</tr>
<tr>
<th scope="row">Feb 2026</th>
<td>$3,940.10</td>
<td>$13,100.00</td>
</tr>
</tbody>
</table>The visual rendering barely changes — a small font-weight shift on the header cells, easily restyled with CSS if the team wants headers that do not look bold by default. What changes entirely is what a screen reader announces: landing on $13,100.00 now reads as "Savings, February 2026: $13,100.00" instead of a bare, unlabeled dollar figure with no indication of which account or month it belongs to. The audit finding closes, and — because this exact table structure is used across four other pages in the app via a shared component — the fix ships everywhere at once.
Four Misconceptions About HTML Tables
5 Interview Questions — With Complete Answers
Table Mistakes Beginners Make Constantly
Errors and Rendering Bugs You Will Hit With Tables — And Exactly Why
🎯 Key Takeaways
- ✓table, tr, td, and th form the required structure — th marks header cells semantically, not just visually, which matters directly for screen reader navigation.
- ✓thead, tbody, and tfoot group rows into structural regions, enabling independent header behavior, targeted CSS styling, and clearer semantics for assistive technology.
- ✓scope="col" and scope="row" on th cells disambiguate header direction — essential for any table with headers on both axes, one of the highest-impact, most-skipped accessibility fixes for data tables.
- ✓colspan and rowspan span a cell across multiple columns or rows — and reduce how many explicit cells the affected row(s) need, a detail that causes most real table misalignment bugs when missed.
- ✓caption, as the first child of table, gives the table an accessible, programmatically connected title — stronger than a nearby heading, which carries no formal relationship to the table.
- ✓Tables were historically used for entire page layouts because early CSS could not reliably build multi-column layouts — a practice abandoned once Flexbox and Grid matured, since table-based layout breaks screen reader navigation, is slow to render, and cannot reflow responsively.
- ✓Tables should hold only genuinely tabular data. Page layout — sidebars, headers, footers, card grids — belongs to semantic elements combined with Flexbox or Grid, covered later in this track.
What comes next
Module 08 moves into HTML forms — the form element, every common input type with real behavioral differences, labels done correctly, and the built-in browser validation you get for free before a single line of JavaScript.
Module 08 → HTML Forms — Inputs & Validation BasicsDiscussion
0Have a better approach? Found something outdated? Share it — your knowledge helps everyone learning here.