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 useWhy it's tabular
Class scheduleDays vs time slots
ScoreboardsPlayers vs scores
Product comparisonProducts vs features
Grade reportStudents vs subjects vs marks
Pricing plansPlans vs features
TimetableRoutes vs departure times
Financial dataPeriods 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

ElementPurpose
<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-describedby on <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

ValueApplies to
colAll cells in the same column
rowAll cells in the same row
colgroupAll cells in a group of columns
rowgroupAll 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.

Tabler table-export icon showing a grid with rows and columns and an export arrow

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 &amp; CSS</th>
        <th scope="col">JavaScript</th>
        <th scope="col">Python &amp; 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

MistakeWhat goes wrongFix
Using <td> with bold for headersScreen readers treat it as data, not a headerUse <th scope="col/row">
Omitting <caption>No visual or accessible label for the tableAlways add <caption> for data tables
No scope on <th>Screen reader cannot map header to cellAdd scope="col" or scope="row"
Using tables for layoutBreaks responsive design and accessibilityUse CSS Grid or Flexbox
Wide table with no scroll wrapperOverflows on mobileWrap in <div style="overflow-x:auto">
Overusing colspan/rowspanConfusing structure, hard to maintainSimplify or split into multiple tables
No tfoot for summary rowsSummary styled as data — no semantic differenceUse <tfoot> for totals and averages
Interactive table (sort) missing aria-sortScreen reader users cannot know sort stateUpdate aria-sort attribute on click

17. Mini Exercises

  1. Build a simple 3-column, 4-row table showing your three favourite movies with columns: Title, Year, Genre. Add a <caption>.
  2. Add <thead>, <tbody>, and <tfoot> (use the tfoot for an "All time classic" row) to the table from exercise 1.
  3. Add scope="col" to column headers and scope="row" to the first cell of each data row. Why does this matter?
  4. Use colspan="2" to merge two header cells under a "Media type" group spanning "Film" and "TV Show" columns.
  5. Apply the zebra-striping CSS from section 8 to any table. Then add a :hover rule.
  6. Wrap a wide table in a scroll container div and verify it scrolls on a narrow viewport.
  7. Add a Download CSV button to a table using the JavaScript from section 12.

18. Review Questions

  1. What is the difference between <th> and <td>?
  2. What does scope="col" tell a screen reader?
  3. When would you use headers instead of scope?
  4. What does colspan="3" do to a cell?
  5. Why should you use <caption> instead of a heading above the table?
  6. What CSS property merges double borders into a single border?
  7. What are two strategies for making a table usable on small screens?
  8. What does aria-sort communicate and when should it be used?
  9. What is <tfoot> used for and why not just style the last row differently?
  10. Name two things a table is appropriate for and two things it is not.