Tables and Tabular Data
Tables are for information that naturally belongs in rows and columns, linked by a shared set of headers. They are not for page layout.
This chapter covers everything: the full set of table elements, colspan and rowspan, complex header associations, styling with CSS, making tables responsive, and accessibility best practices.
1. When to Use a Table
Good Use Cases
| Good use | Why it's tabular |
|---|---|
| Class schedule | Days vs time slots |
| Scoreboards | Players vs scores |
| Product comparison | Products vs features |
| Grade report | Students vs subjects vs marks |
| Pricing plans | Plans vs features |
| Timetable | Routes vs departure times |
| Financial data | Periods vs figures |
Bad Use Cases (Don't Do These)
<!-- ❌ Never use tables for layout -->
<table>
<tr>
<td><nav>...</nav></td>
<td><main>...</main></td>
<td><aside>...</aside></td>
</tr>
</table>
<!-- ✅ Use CSS Grid or Flexbox instead -->
<div class="layout">
<nav>...</nav>
<main>...</main>
<aside>...</aside>
</div>
Using tables for layout was common before CSS. Today it breaks accessibility, responsive design, and code maintainability.
2. Core Table Elements
The Full Structure
<table>
<caption>Student Exam Scores – Spring Term</caption>
<colgroup>
<col> <!-- Name column -->
<col span="3"> <!-- Three score columns -->
</colgroup>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">HTML</th>
<th scope="col">CSS</th>
<th scope="col">JavaScript</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Asha</th>
<td>92</td>
<td>88</td>
<td>95</td>
</tr>
<tr>
<th scope="row">Leo</th>
<td>85</td>
<td>90</td>
<td>78</td>
</tr>
<tr>
<th scope="row">Priya</th>
<td>97</td>
<td>94</td>
<td>99</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Average</th>
<td>91</td>
<td>91</td>
<td>91</td>
</tr>
</tfoot>
</table>
Element Reference
| Element | Purpose |
|---|---|
<table> | Wraps the entire table |
<caption> | Visible title or summary above the table |
<colgroup> | Defines one or more columns for styling |
<col> | Represents a single column (inside <colgroup>) |
<thead> | Groups header rows |
<tbody> | Groups the main data rows |
<tfoot> | Groups footer/summary rows |
<tr> | A table row |
<th> | A header cell (bold, centred by default) |
<td> | A data cell |
3. The <caption> Element
<caption> is the first child of <table> and gives the table a visible name. It is critical for accessibility — screen readers announce it before reading any cells.
<table aria-describedby="score-note">
<caption>Nandhoo Student Assessment – Quarter 3</caption>
...
</table>
<p id="score-note">Scores are out of 100. Pass mark is 60.</p>
- Place
<caption>immediately after<table>— never after<thead> - Use
aria-describedbyon<table>to link to a paragraph that gives extra context
4. The scope Attribute
scope on <th> tells assistive technologies which cells that header applies to.
<thead>
<tr>
<!-- This header applies to the column below it -->
<th scope="col">Subject</th>
<th scope="col">Score</th>
</tr>
</thead>
<tbody>
<tr>
<!-- This header applies to the cells to its right -->
<th scope="row">Asha</th>
<td>92</td>
</tr>
</tbody>
scope Values
| Value | Applies to |
|---|---|
col | All cells in the same column |
row | All cells in the same row |
colgroup | All cells in a group of columns |
rowgroup | All cells in a group of rows |
5. colspan and rowspan
colspan — Span Across Multiple Columns
<tr>
<!-- This header spans 3 columns -->
<th colspan="3" scope="colgroup">Semester 1</th>
</tr>
<tr>
<th scope="col">Jan</th>
<th scope="col">Feb</th>
<th scope="col">Mar</th>
</tr>
rowspan — Span Across Multiple Rows
<tbody>
<tr>
<!-- "Backend" spans two rows -->
<th rowspan="2" scope="rowgroup">Backend</th>
<td>Python</td>
<td>Advanced</td>
</tr>
<tr>
<!-- no <th> here — covered by the rowspan above -->
<td>Node.js</td>
<td>Intermediate</td>
</tr>
</tbody>
Full Example — Timetable with Both
<table>
<caption>Nandhoo Weekly Class Schedule</caption>
<thead>
<tr>
<th scope="col" rowspan="2">Course</th>
<th scope="colgroup" colspan="2">Monday</th>
<th scope="colgroup" colspan="2">Wednesday</th>
</tr>
<tr>
<th scope="col">Start</th>
<th scope="col">End</th>
<th scope="col">Start</th>
<th scope="col">End</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">HTML5</th>
<td>10:00</td>
<td>11:00</td>
<td>10:00</td>
<td>11:00</td>
</tr>
<tr>
<th scope="row">Python</th>
<td>11:30</td>
<td>12:30</td>
<td>11:30</td>
<td>12:30</td>
</tr>
</tbody>
</table>
6. The headers Attribute for Complex Tables
For very complex tables where scope is not sufficient, use id on header cells and headers on data cells to explicitly link them.
<table>
<caption>Course Completion by Region and Level</caption>
<thead>
<tr>
<th id="region">Region</th>
<th id="beginner">Beginner</th>
<th id="intermediate">Intermediate</th>
<th id="advanced">Advanced</th>
</tr>
</thead>
<tbody>
<tr>
<th id="eu" scope="row">Europe</th>
<td headers="eu beginner">1,240</td>
<td headers="eu intermediate">870</td>
<td headers="eu advanced">310</td>
</tr>
<tr>
<th id="asia" scope="row">Asia</th>
<td headers="asia beginner">2,100</td>
<td headers="asia intermediate">1,450</td>
<td headers="asia advanced">620</td>
</tr>
</tbody>
</table>
Each headers attribute lists multiple id values separated by spaces. A screen reader will announce: "Europe – Beginner – 1,240" for the first data cell.
7. <colgroup> and <col> for Column Styling
<colgroup> defines logical groups of columns. <col> targets individual columns. This is the only clean way to style entire columns with CSS without repeating classes on every <td>.
<table>
<caption>Product Pricing</caption>
<colgroup>
<col class="col-product"> <!-- column 1: product name -->
<col class="col-price" span="3"> <!-- columns 2-4: prices -->
</colgroup>
<thead>
<tr>
<th scope="col">Product</th>
<th scope="col">Basic</th>
<th scope="col">Pro</th>
<th scope="col">Enterprise</th>
</tr>
</thead>
...
</table>
.col-product { width: 40%; }
.col-price { width: 20%; text-align: right; }
Note:
<col>only supports a limited set of CSS properties. For full styling, add classes to<td>and<th>directly.
8. Styling Tables with CSS
Basic Zebra-Stripe Table
table {
border-collapse: collapse; /* merge adjacent borders into one */
width: 100%;
font-family: system-ui, sans-serif;
}
caption {
font-size: 1.1rem;
font-weight: bold;
text-align: left;
margin-bottom: 0.5rem;
color: #333;
}
th, td {
padding: 0.75rem 1rem;
text-align: left;
border: 1px solid #e2e8f0;
}
thead th {
background-color: #1e3a5f;
color: #ffffff;
}
tbody tr:nth-child(even) {
background-color: #f0f4f8; /* zebra striping */
}
tbody tr:hover {
background-color: #dbeafe; /* highlight on hover */
}
tfoot td {
font-weight: bold;
background-color: #f8fafc;
border-top: 2px solid #cbd5e0;
}
border-collapse Explained
border-collapse: separate (default) border-collapse: collapse
┌──┬──┬──┐ ┌─┬─┬─┐
│ │ │ │ ← double borders │ │ │ │ ← single borders
├──┼──┼──┤ ├─┼─┼─┤
│ │ │ │ │ │ │ │
└──┴──┴──┘ └─┴─┴─┘
Always use border-collapse: collapse for clean-looking data tables.
9. Visual Reference
The icon below is a helpful visual reminder of what tabular data looks like — rows, columns, and exportable structure.
![]()
Source: Wikimedia Commons – Tabler-icons table-export.svg · CC0 Public Domain
Text description: A simple table-export icon showing a 3×3 grid with horizontal and vertical lines forming cells, and an upward-right arrow symbolising data export or sharing. It represents the concept of structured, exportable tabular data.
10. Responsive Tables
On small screens, wide tables overflow and become unreadable. There are several strategies.
Strategy 1: Horizontal Scroll Wrapper (Simplest)
<div class="table-scroll">
<table>...</table>
</div>
.table-scroll {
overflow-x: auto;
-webkit-overflow-scrolling: touch; /* smooth scroll on iOS */
}
table {
min-width: 600px; /* don't shrink below this */
}
Best for: data tables that must preserve all columns.
Strategy 2: Stack Cells on Mobile
Turn each row into a card by hiding the <thead> and using data-label attributes to show column names next to each cell.
<table>
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Score</th>
<th scope="col">Grade</th>
</tr>
</thead>
<tbody>
<tr>
<td data-label="Name">Asha</td>
<td data-label="Score">92</td>
<td data-label="Grade">A</td>
</tr>
</tbody>
</table>
@media (max-width: 600px) {
thead {
display: none; /* hide header row */
}
tr {
display: block;
margin-bottom: 1rem;
border: 1px solid #e2e8f0;
border-radius: 4px;
padding: 0.5rem;
}
td {
display: flex;
justify-content: space-between;
padding: 0.4rem 0.75rem;
border-bottom: 1px solid #f0f4f8;
}
td::before {
content: attr(data-label); /* display "Name:", "Score:", etc. */
font-weight: bold;
color: #555;
margin-right: 0.5rem;
}
}
Best for: short rows with 3–5 columns that work well as labelled pairs.
Strategy 3: Priority Columns (Hide Less Important Columns)
/* always show */
.col-name { }
/* hide on small screens */
@media (max-width: 480px) {
.col-detail { display: none; }
}
<th scope="col" class="col-detail">Detail</th>
<td class="col-detail">...</td>
11. Sortable Tables with JavaScript
A sortable table lets users click column headers to reorder rows.
<table id="score-table">
<caption>Student Scores (click a header to sort)</caption>
<thead>
<tr>
<th scope="col" data-col="0" class="sortable" aria-sort="none">Name</th>
<th scope="col" data-col="1" class="sortable" aria-sort="none">Score</th>
</tr>
</thead>
<tbody>
<tr><td>Priya</td><td>97</td></tr>
<tr><td>Asha</td><td>92</td></tr>
<tr><td>Leo</td><td>85</td></tr>
<tr><td>Mia</td><td>78</td></tr>
</tbody>
</table>
document.querySelectorAll('th.sortable').forEach(th => {
th.style.cursor = 'pointer';
th.addEventListener('click', () => {
const table = th.closest('table');
const tbody = table.querySelector('tbody');
const colIdx = parseInt(th.dataset.col, 10);
const asc = th.getAttribute('aria-sort') !== 'ascending';
// Sort rows
const rows = [...tbody.querySelectorAll('tr')];
rows.sort((a, b) => {
const aVal = a.cells[colIdx].textContent.trim();
const bVal = b.cells[colIdx].textContent.trim();
const aNum = parseFloat(aVal);
const bNum = parseFloat(bVal);
if (!isNaN(aNum) && !isNaN(bNum)) return asc ? aNum - bNum : bNum - aNum;
return asc ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
});
rows.forEach(row => tbody.appendChild(row));
// Update aria-sort on all headers
table.querySelectorAll('th.sortable').forEach(t =>
t.setAttribute('aria-sort', 'none')
);
th.setAttribute('aria-sort', asc ? 'ascending' : 'descending');
});
});
th.sortable:hover { background-color: #2d5282; }
th[aria-sort="ascending"]::after { content: ' ▲'; font-size: 0.75rem; }
th[aria-sort="descending"]::after { content: ' ▼'; font-size: 0.75rem; }
The aria-sort attribute (none, ascending, descending) communicates sort state to screen readers automatically.
12. Exporting Table Data
Tables in HTML can be exported to CSV with a simple script, making them useful for data-driven dashboards.
function tableToCSV(tableId) {
const table = document.getElementById(tableId);
const rows = [...table.querySelectorAll('tr')];
return rows.map(row => {
const cells = [...row.querySelectorAll('th, td')];
return cells.map(cell => `"${cell.textContent.replace(/"/g, '""')}"`).join(',');
}).join('\n');
}
function downloadCSV(tableId, filename = 'data.csv') {
const csv = tableToCSV(tableId);
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
<button onclick="downloadCSV('score-table', 'scores.csv')">
Download as CSV
</button>
13. Printing Tables
Tables often appear in printed reports. Control print layout with CSS @media print.
@media print {
table {
border-collapse: collapse;
width: 100%;
font-size: 10pt;
}
th, td {
border: 1px solid #000;
padding: 4pt 8pt;
}
thead {
/* Repeat headers on every printed page */
display: table-header-group;
}
tfoot {
display: table-footer-group;
}
tr {
page-break-inside: avoid; /* don't split a row across pages */
}
caption {
font-size: 14pt;
font-weight: bold;
margin-bottom: 8pt;
}
/* Hide interactive elements in print */
.table-controls, button { display: none; }
}
14. A Complete Real-World Table Example
A pricing comparison table with grouped columns, footer totals, and full accessibility:
<div class="table-scroll">
<table id="pricing-table" aria-describedby="pricing-note">
<caption>Nandhoo Course Bundle Pricing</caption>
<colgroup>
<col class="col-plan">
<col class="col-price" span="3">
</colgroup>
<thead>
<tr>
<th scope="col" rowspan="2">Plan</th>
<th scope="colgroup" colspan="3">Included Courses</th>
</tr>
<tr>
<th scope="col">HTML & CSS</th>
<th scope="col">JavaScript</th>
<th scope="col">Python & AI</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Starter — Free</th>
<td>✓ First 3 chapters</td>
<td>—</td>
<td>—</td>
</tr>
<tr>
<th scope="row">Explorer — £9/mo</th>
<td>✓ Full access</td>
<td>✓ Full access</td>
<td>—</td>
</tr>
<tr>
<th scope="row">Pro — £19/mo</th>
<td>✓ Full access</td>
<td>✓ Full access</td>
<td>✓ Full access</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Certificates included</th>
<td>No</td>
<td>Yes</td>
<td>Yes</td>
</tr>
</tfoot>
</table>
</div>
<p id="pricing-note">Prices shown in GBP. VAT may apply depending on your country.</p>
15. Accessibility Checklist for Tables
Structure:
[ ] <caption> present for all data tables
[ ] <thead>, <tbody>, <tfoot> used correctly
[ ] <th> used for all header cells, not styled <td>
[ ] scope="col" on column headers
[ ] scope="row" on row headers
Complex tables:
[ ] id + headers used when scope is insufficient
[ ] Grouped headers use scope="colgroup" or scope="rowgroup"
Usability:
[ ] No tables used for layout
[ ] aria-sort attributes on sortable columns
[ ] aria-describedby links to summary paragraph when needed
[ ] Responsive: either scroll wrapper or stacking solution for mobile
16. Common Mistakes and Fixes
| Mistake | What goes wrong | Fix |
|---|---|---|
Using <td> with bold for headers | Screen readers treat it as data, not a header | Use <th scope="col/row"> |
Omitting <caption> | No visual or accessible label for the table | Always add <caption> for data tables |
No scope on <th> | Screen reader cannot map header to cell | Add scope="col" or scope="row" |
| Using tables for layout | Breaks responsive design and accessibility | Use CSS Grid or Flexbox |
| Wide table with no scroll wrapper | Overflows on mobile | Wrap in <div style="overflow-x:auto"> |
Overusing colspan/rowspan | Confusing structure, hard to maintain | Simplify or split into multiple tables |
No tfoot for summary rows | Summary styled as data — no semantic difference | Use <tfoot> for totals and averages |
Interactive table (sort) missing aria-sort | Screen reader users cannot know sort state | Update aria-sort attribute on click |
17. Mini Exercises
- Build a simple 3-column, 4-row table showing your three favourite movies with columns: Title, Year, Genre. Add a
<caption>. - Add
<thead>,<tbody>, and<tfoot>(use the tfoot for an "All time classic" row) to the table from exercise 1. - Add
scope="col"to column headers andscope="row"to the first cell of each data row. Why does this matter? - Use
colspan="2"to merge two header cells under a "Media type" group spanning "Film" and "TV Show" columns. - Apply the zebra-striping CSS from section 8 to any table. Then add a
:hoverrule. - Wrap a wide table in a scroll container div and verify it scrolls on a narrow viewport.
- Add a Download CSV button to a table using the JavaScript from section 12.
18. Review Questions
- What is the difference between
<th>and<td>? - What does
scope="col"tell a screen reader? - When would you use
headersinstead ofscope? - What does
colspan="3"do to a cell? - Why should you use
<caption>instead of a heading above the table? - What CSS property merges double borders into a single border?
- What are two strategies for making a table usable on small screens?
- What does
aria-sortcommunicate and when should it be used? - What is
<tfoot>used for and why not just style the last row differently? - Name two things a table is appropriate for and two things it is not.