When to use a table (and when not)
Use a <table> for tabular data — information that has a natural relationship across rows and columns, where the row and column together give a cell its meaning.
- Yes: a price list, a class schedule, sports stats, a feature comparison, a bank statement.
- No: page layout. Tables were misused for layout in the 1990s — today that's CSS Grid & Flexbox's job.
The anatomy of a table
A table nests a few elements. Here's the family tree:
<th> = header cell (bold & centered by default, and announced as a header), <td> = data cell. Rows are <tr> (table row).Build one, step by step
Here's a minimal table — code on top, the rendered result below.
<table> <thead> <tr><th>Item</th><th>Price</th></tr> </thead> <tbody> <tr><td>Coffee</td><td>$3</td></tr> <tr><td>Tea</td><td>$2</td></tr> </tbody> </table>
| Item | Price |
|---|---|
| Coffee | $3 |
| Tea | $2 |
Header cells & scope
Headers can run across the top (columns) and down the side (rows). Add scope so screen readers announce each data cell with its headers — e.g. “Tuesday, HTML, 2 hours.”
<tr> <th scope="col">Week</th> column header <th scope="col">HTML</th> </tr> <tr> <th scope="row">Week 1</th> row header <td>3 hrs</td> </tr>
| Week | HTML | CSS |
|---|---|---|
| Week 1 | 3 hrs | 2 hrs |
| Week 2 | 2 hrs | 4 hrs |
scope, a screen reader just reads numbers; with it, every cell is announced in context.Give it a <caption>
The <caption> is the table's title. It must be the first child of <table>, and it's read aloud first so users know what the table is about before diving into cells.
<table> <caption>Weekly study hours</caption> … </table>
| Week | Hours |
|---|---|
| Week 1 | 5 |
| Week 2 | 6 |
Spanning cells: colspan & rowspan
A cell can stretch across multiple columns (colspan) or down multiple rows (rowspan) — for grouped headers or merged labels.
<th colspan="2">Weekend</th> one cell, two columns wide <td rowspan="2">Closed</td> one cell, two rows tall
| Day | Hours | |
|---|---|---|
| Open | Close | |
| Mon | 9 | 5 |
| Sat–Sun | Closed | |
colspan is the classic reason a table looks broken.<thead>, <tbody>, <tfoot>
These group the rows by role. Beyond meaning, they enable nice touches: a sticky header when the body scrolls, and a totals row in the foot.
| Month | Sales |
|---|---|
| Jan | $1,200 |
| Feb | $1,500 |
| Mar | $1,800 |
| Total | $4,500 |
thead th { position: sticky; top: 0; } keeps the header visible while a long <tbody> scrolls.Styling: borders, zebra stripes, hover
A few CSS rules turn a default table into a readable one. The key one is border-collapse: collapse so borders don't double up.
table { border-collapse: collapse; width: 100%; } th, td { padding: 10px 12px; text-align: left; } tbody tr:nth-child(even) { background: #eef4fb; } zebra stripes tbody tr:hover { background: #dbeafe; } row highlight
| Name | Role | City |
|---|---|---|
| Ada Lovelace | Engineer | London |
| Grace Hopper | Engineer | New York |
| Mae Jemison | Scientist | Decatur |
| Katherine Johnson | Mathematician | Hampton |
text-align: right) so they line up by place value, and keep generous padding — cramped tables are hard to scan.Tables on small screens
Wide tables overflow a phone. The simplest, most reliable fix: wrap the table in a scrolling container so it scrolls sideways instead of breaking the layout.
<div class="table-wrap"> <table> … </table> </div> .table-wrap { overflow-x: auto; } scroll instead of overflow
| Plan | Price | Storage | Users | Support |
|---|---|---|---|---|
| Free | $0 | 1 GB | 1 | |
| Pro | $12 | 100 GB | 5 | Priority |
| Team | $49 | 1 TB | Unlimited | 24/7 |
↑ On a narrow screen, swipe this table sideways — it scrolls instead of squishing.
data-label attributes). The scroll wrapper is simpler and works for almost everything.Putting it together: one big table
This workshop schedule uses nearly everything at once — grouped headers with colspan, sessions that merge down two time slots with rowspan, <thead>/<tbody>/<tfoot>, scope on every header, and CSS zebra striping with :nth-child(). Rendered table first, then the HTML and CSS — each with its own copy button.
| Time | Web Track | Design Track | ||
|---|---|---|---|---|
| HTML / CSS | JavaScript | UX | Figma | |
| 8:00 | Check-in | |||
| 9:00 | HTML Basics | Intro JS | Wireframes | Figma 101 |
| 10:00 | JS Functions | UX Research | Components | |
| 11:00 | Flexbox | The DOM | Prototyping | Handoff |
| 12:00 | Lunch | |||
| 1:00 | CSS Grid | APIs | Testing | Tokens |
| 2:00 | Project Lab | Project | Project | Project |
| 3:00 | Review | Review | Review | |
| 4:00 | Office Hours | Open lab | Open lab | Open lab |
| Doors close at 5:00 - see you tomorrow | ||||
HTML
<table class="sched">
<caption>Fall Workshop Schedule</caption>
<thead>
<tr>
<th scope="col" rowspan="2">Time</th>
<th scope="col" colspan="2">Web Track</th> <!-- one header, 2 columns -->
<th scope="col" colspan="2">Design Track</th>
</tr>
<tr>
<th scope="col">HTML / CSS</th><th scope="col">JavaScript</th>
<th scope="col">UX</th><th scope="col">Figma</th>
</tr>
</thead>
<tbody>
<tr><th scope="row">8:00</th><td class="full" colspan="4">Check-in</td></tr>
<tr>
<th scope="row">9:00</th>
<td rowspan="2">HTML Basics</td> <!-- spans 9:00 + 10:00 -->
<td>Intro JS</td><td>Wireframes</td><td>Figma 101</td>
</tr>
<tr>
<th scope="row">10:00</th>
<td>JS Functions</td><td>UX Research</td><td>Components</td>
</tr>
<tr>
<th scope="row">11:00</th>
<td>Flexbox</td><td>The DOM</td><td>Prototyping</td><td>Handoff</td>
</tr>
<tr><th scope="row">12:00</th><td class="full" colspan="4">Lunch</td></tr>
<tr>
<th scope="row">1:00</th>
<td>CSS Grid</td><td>APIs</td><td>Testing</td><td>Tokens</td>
</tr>
<tr>
<th scope="row">2:00</th>
<td rowspan="2">Project Lab</td> <!-- spans 2:00 + 3:00 -->
<td>Project</td><td>Project</td><td>Project</td>
</tr>
<tr>
<th scope="row">3:00</th>
<td>Review</td><td>Review</td><td>Review</td>
</tr>
<tr>
<th scope="row">4:00</th>
<td>Office Hours</td><td>Open lab</td><td>Open lab</td><td>Open lab</td>
</tr>
</tbody>
<tfoot>
<tr><td colspan="5">Doors close at 5:00 - see you tomorrow</td></tr>
</tfoot>
</table>
CSS
.sched { border-collapse: collapse; width: 100%; font-size: .85rem; }
.sched caption { text-align: left; font-weight: 700; padding-bottom: 8px; }
.sched th,
.sched td { border: 1px solid #cbd5e1; padding: 8px 10px; text-align: left; }
.sched thead th { background: #0f172a; color: #fff; } /* dark column headers */
.sched tbody th { background: #eef2f6; } /* row (time) labels */
/* Zebra striping — shade every even body row */
.sched tbody tr:nth-child(even) { background: #f1f5f9; }
/* Spotlight one specific row — here the 9th */
.sched tbody tr:nth-child(9) td { background: #dbeafe; }
.sched .full { text-align: center; font-style: italic; background: #fff7ed; }
.sched tfoot td { background: #0f172a; color: #e5e7eb; text-align: center; }
colspan stretches the “Web Track” header and the Check-in / Lunch rows across columns; rowspan makes “HTML Basics” and “Project Lab” cover two time slots. The CSS stripes every even row with :nth-child(even) and spotlights just the 9th row with :nth-child(9) — and the whole table sits in an overflow-x:auto wrapper so it scrolls on phones.Table checklist
- Used only for tabular data, never page layout
- Has a
<caption>describing what it shows - Column and row headers use
<th>withscope="col"/scope="row" - Rows grouped in
<thead>/<tbody>(and<tfoot>for totals) - Every row has the same column count once
colspan/rowspanare counted -
border-collapse: collapse, comfortable padding, numbers right-aligned - Wrapped in an
overflow-x: autocontainer so it scrolls on phones