EthicsComputerCourses
Join training +91 90532 10052

Accessible Data Tables

Web Accessibility · Lesson 9 of 28 Beginner 6 min read Updated

A sighted user reads a table by glancing up to the column heading and left to the row heading. A screen reader user moves one cell at a time, so the screen reader must announce those headings for each cell. It can only do that if the table's headers are marked up correctly.

In this lesson you will build a train timetable that screen readers can navigate, learn <caption>, <th> and scope, handle tables with two levels of headings, add a sortable column, and make a wide table usable on a phone.

The building blocks

Element or attributeJob
<table>A grid of related data
<caption>The table's title; screen readers announce it when the user enters the table
<th>A header cell for a column or a row
scope="col" or scope="row"Says which cells a header applies to
<thead>, <tbody>, <tfoot>Group header, body and footer rows
headers="id1 id2"Links a data cell to its header cells in complex tables

WCAG 1.3.1 Info and Relationships (A) requires that the relationships shown visually, such as "this cell belongs to this column", are also in the code.

A simple data table

Run this example. Then, if you have NVDA, move through the cells with Ctrl+Alt+arrow keys: when you move to a new column or row, NVDA reads its header, such as "Departs, 06:10".

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Train Timetable</title>
  <style>
    table { border-collapse: collapse; font: 16px/1.4 system-ui, sans-serif; }
    caption { font-weight: bold; text-align: left; padding-bottom: 6px; }
    th, td { border: 1px solid #888; padding: 6px 10px; text-align: left; }
    thead th { background: #e8eef7; }
  </style>
</head>
<body>
  <table>
    <caption>Trains from Rohtak to New Delhi, weekdays</caption>
    <thead>
      <tr>
        <th scope="col">Train</th>
        <th scope="col">Departs</th>
        <th scope="col">Arrives</th>
        <th scope="col">Fare (2S)</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <th scope="row">Rohtak Express</th>
        <td>06:10</td>
        <td>08:05</td>
        <td>₹95</td>
      </tr>
      <tr>
        <th scope="row">Haryana Passenger</th>
        <td>09:25</td>
        <td>11:50</td>
        <td>₹60</td>
      </tr>
    </tbody>
  </table>
</body>
</html>

The train names are row headers (<th scope="row">), so a user in the "Arrives" column of the second row hears "Haryana Passenger, Arrives, 11:50". The timetable details in this example are made up for practice.

For simple tables with one row of column headers, browsers can usually guess the relationships even without scope. Adding it costs nothing and removes the guesswork.

Tables with two levels of headings

When a header covers several columns, or the table has headers in more than one row, scope may not be enough. Then give each header cell an id and list the matching ids in each data cell's headers attribute.

HTML
<table>
  <caption>Kochi Metro ridership, thousands per day</caption>
  <tr>
    <td></td>
    <th id="wd" colspan="2" scope="colgroup">Weekdays</th>
    <th id="we" colspan="2" scope="colgroup">Weekends</th>
  </tr>
  <tr>
    <td></td>
    <th id="wd-am" scope="col">Morning</th><th id="wd-pm" scope="col">Evening</th>
    <th id="we-am" scope="col">Morning</th><th id="we-pm" scope="col">Evening</th>
  </tr>
  <tr>
    <th id="aug" scope="row">August</th>
    <td headers="aug wd wd-am">41</td><td headers="aug wd wd-pm">46</td>
    <td headers="aug we we-am">22</td><td headers="aug we we-pm">35</td>
  </tr>
</table>

If a table needs this much markup, ask whether it would be clearer as two simple tables. Simple tables are easier for everyone to read.

Sortable columns

When users can sort a table, put a <button> inside each sortable header, and mark the current sort with aria-sort on the <th>. Its values are ascending, descending, none and other. Put it on only one column at a time.

HTMLTry it yourself
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>Sortable Table</title>
  <style>
    table { border-collapse: collapse; }
    th, td { border: 1px solid #888; padding: 6px 10px; text-align: left; }
    th button { font: inherit; font-weight: bold; background: none; border: 0; padding: 0; cursor: pointer; }
  </style>
</head>
<body>
  <table>
    <caption>Cricket academy batting averages, 2026 season</caption>
    <thead>
      <tr>
        <th scope="col">Player</th>
        <th scope="col" aria-sort="none" id="avg-th"><button type="button" id="sort">Average</button></th>
      </tr>
    </thead>
    <tbody id="rows">
      <tr><th scope="row">Aarav</th><td>38.5</td></tr>
      <tr><th scope="row">Meera</th><td>52.0</td></tr>
      <tr><th scope="row">Farhan</th><td>44.2</td></tr>
    </tbody>
  </table>
  <script>
    const th = document.querySelector("#avg-th");
    const body = document.querySelector("#rows");
    document.querySelector("#sort").addEventListener("click", () => {
      const next = th.getAttribute("aria-sort") === "descending" ? "ascending" : "descending";
      const rows = [...body.rows].sort((a, b) => {
        const diff = Number(a.cells[1].textContent) - Number(b.cells[1].textContent);
        return next === "ascending" ? diff : -diff;
      });
      body.append(...rows);
      th.setAttribute("aria-sort", next);
    });
  </script>
</body>
</html>

The button gives keyboard access, and aria-sort lets screen readers say "Average, sorted descending". Inspect the header in the Accessibility tab of your developer tools to see the sort state change.

Wide tables on small screens

WCAG 1.4.10 Reflow allows two-dimensional content such as data tables to scroll sideways, but keyboard users must be able to scroll it too. Wrap the table in a scrollable region that can take focus:

HTML
<div class="table-scroll" role="region" aria-labelledby="fee-cap" tabindex="0">
  <table>
    <caption id="fee-cap">Hostel fees by room type, 2026-27</caption>
    ...
  </table>
</div>
<style>
  .table-scroll { overflow-x: auto; }
  .table-scroll:focus-visible { outline: 3px solid #0b5cad; }
</style>

tabindex="0" lets keyboard users focus the box and scroll it with the arrow keys, and the role and name tell screen reader users what the focusable box is.

Common mistakes

  • Using <td> with bold text as headers. Use <th>, so headers are announced.
  • No caption. Users entering the table do not know what it contains.
  • Merged cells everywhere. Complex spans are hard to navigate; split into simpler tables.
  • Changing display on table elements in CSS. Setting display: block or grid on table parts has removed table semantics in some browsers. Test with a screen reader if you must restyle a table.
  • Empty cells for layout spacing. Use CSS padding instead.

Key points

  • Use tables only for data in rows and columns.
  • Give each table a <caption>, and mark header cells with <th> and scope.
  • For multi-level headers, use id on headers and headers on data cells, or split the table.
  • Sortable headers contain a <button>, and the sorted <th> has aria-sort.
  • Wrap wide tables in a focusable, labelled scroll region.

Exercise

Build an accessible table for a Pune coaching centre's batch timings. Columns: Batch, Days, Time, Seats left. Rows: Morning JEE (Mon to Fri, 7 to 9 am, 4 seats), Evening NEET (Mon to Sat, 5 to 7 pm, 0 seats), Weekend Foundation (Sat and Sun, 10 am to 1 pm, 12 seats). Add a caption and row headers.

Show solution
HTML
<table>
  <caption>Batch timings, Pune centre, November 2026</caption>
  <thead>
    <tr>
      <th scope="col">Batch</th>
      <th scope="col">Days</th>
      <th scope="col">Time</th>
      <th scope="col">Seats left</th>
    </tr>
  </thead>
  <tbody>
    <tr><th scope="row">Morning JEE</th><td>Mon to Fri</td><td>7 to 9 am</td><td>4</td></tr>
    <tr><th scope="row">Evening NEET</th><td>Mon to Sat</td><td>5 to 7 pm</td><td>0 (full)</td></tr>
    <tr><th scope="row">Weekend Foundation</th><td>Sat and Sun</td><td>10 am to 1 pm</td><td>12</td></tr>
  </tbody>
</table>

The batch names are row headers, the column headers use scope="col", and "0 (full)" explains the status in words instead of only colouring the cell red.

Quick Quiz

1. Which element gives a table its title for screen readers?

2. What does scope="row" on a <th> mean?

3. Where should aria-sort go in a sortable table?

4. How can keyboard users scroll a wide table inside a scrolling box?

Learn Web Accessibility with a mentor

Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call