Tables
A table is for data with two dimensions: rows that mean something, columns that mean something, and cells that mean the intersection of the two. Sales by quarter. Students by assessment. Timetable by day.
It is not for layout. That use is twenty years out of date and it is worth knowing why it was abandoned rather than just that it was: a screen reader reading a layout table announces "table, 3 columns, 14 rows" and then reads a navigation menu cell by cell as though it were data. CSS Grid does layout, and does it responsively, which a table never did.
The whole structure
<table>
<caption>Course completions by quarter, 2026</caption>
<thead>
<tr>
<th scope="col">Course</th>
<th scope="col">Q1</th>
<th scope="col">Q2</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">HTML</th>
<td>128</td>
<td>164</td>
</tr>
<tr>
<th scope="row">CSS</th>
<td>96</td>
<td>131</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row">Total</th>
<td>224</td>
<td>295</td>
</tr>
</tfoot>
</table>
Open this example in the editor and change it. Nothing you type is saved.
Six elements, and each earns its place.
caption names the table, and it goes first, immediately after the opening table tag. It is the table's heading: a screen reader reads it before anything else, so somebody skipping through the page knows whether to stop. It is the single most valuable line in the whole example and the one most often left out.
thead, tbody and tfoot group the rows. They let a long table scroll its body under a fixed head in CSS, they let a printer repeat the header row on each page, and they tell assistive technology which row is the header.
tr is a row. th is a header cell, td is a data cell.
scope is what makes it readable
This is the part that changes the experience, and it is one attribute.
scope="col" on a header cell says it heads the column beneath it. scope="row" says it heads the row beside it. With them, a screen reader reading the cell 164 announces "HTML, Q2, 164" - it looks up the row header and the column header and reads them with the value.
Without them, it reads "164". Then "96". Then "131". The numbers are all there and none of them mean anything.
The rule: every th gets a scope. There is no case where you want a header cell that heads nothing.
colspan and rowspan
A cell can span several columns or rows when the data genuinely merges.
<table>
<caption>Timetable</caption>
<thead>
<tr>
<th scope="col">Time</th>
<th scope="col">Monday</th>
<th scope="col">Tuesday</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">09:00</th>
<td colspan="2">Induction, both days</td>
</tr>
<tr>
<th scope="row">11:00</th>
<td>HTML</td>
<td>CSS</td>
</tr>
</tbody>
</table>
Open this example in the editor and change it. Nothing you type is saved.
The arithmetic has to work: each row's cells, counting spans, must add up to the same total. A row that comes to four columns in a three-column table does not produce an error - the browser widens the table and the layout quietly goes wrong from that row down.
Use them when the data merges, not to achieve a visual effect. Heavily merged tables are hard to read aloud, and at that point the data usually wants splitting into two simpler tables.
When the headers are not simple
For a table with two levels of column headers, scope is not enough to express which header belongs to which cell. The explicit form uses id on the headers and headers on the cells.
<table>
<caption>Revenue by region and quarter</caption>
<tbody>
<tr>
<td></td>
<th scope="col" id="h-q1">Q1</th>
<th scope="col" id="h-q2">Q2</th>
</tr>
<tr>
<th scope="row" id="h-north">North</th>
<td headers="h-north h-q1">1204</td>
<td headers="h-north h-q2">1388</td>
</tr>
<tr>
<th scope="row" id="h-south">South</th>
<td headers="h-south h-q1">980</td>
<td headers="h-south h-q2">1102</td>
</tr>
</tbody>
</table>
Open this example in the editor and change it. Nothing you type is saved.
Every cell names the headers that apply to it, by id. It is verbose, and the verbosity is the signal: if a table needs this, it is complex enough that a reader may need the simpler version too.
Styling whole columns
A table is built out of rows, so there is no element that contains a column - which makes styling one awkward. colgroup and col exist for exactly that.
<table>
<caption>Marks</caption>
<colgroup>
<col>
<col class="numeric">
<col class="numeric">
</colgroup>
<thead>
<tr>
<th scope="col">Student</th>
<th scope="col">Theory</th>
<th scope="col">Practical</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Amina</th>
<td>92</td>
<td>88</td>
</tr>
</tbody>
</table>
Open this example in the editor and change it. Nothing you type is saved.
One col per column, in order, and a class on it reaches every cell in that column. Only a few CSS properties apply through col - background, border, width and visibility - but those are the ones column styling usually needs.
Saying a table is sorted
If your table can be sorted, say so in the markup rather than only in an arrow drawn with CSS.
<table>
<caption>Courses by completions</caption>
<thead>
<tr>
<th scope="col"><button type="button">Course</button></th>
<th scope="col" aria-sort="descending">
<button type="button">Completions</button>
</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">HTML</th>
<td>292</td>
</tr>
<tr>
<th scope="row">CSS</th>
<td>227</td>
</tr>
</tbody>
</table>
Open this example in the editor and change it. Nothing you type is saved.
aria-sort goes on the header of the column currently sorted, with ascending, descending or none, and only one header carries it at a time. The header's control is a real button, so it is reachable by keyboard without any of the tabindex work a clickable th would need.
A table on a phone
A table does not reflow. It is a grid, and a grid 700 pixels wide stays 700 pixels wide on a 360-pixel screen.
The honest fix is to let it scroll horizontally inside a container, and to make that container focusable so a keyboard user can scroll it:
<div role="region" aria-label="Course completions" tabindex="0" style="overflow-x: auto">
<table>
<caption>Course completions by quarter</caption>
<tbody>
<tr>
<th scope="row">HTML</th>
<td>128</td>
<td>164</td>
</tr>
</tbody>
</table>
</div>
Open this example in the editor and change it. Nothing you type is saved.
The tabindex="0" is the part that is usually missing: without it, somebody navigating by keyboard can reach the table but cannot scroll it sideways to see the columns that are off screen.
Two small things that bite
The empty corner cell. A table with both row and column headers has a cell at the top left that belongs to neither. Write it as an empty td, as in the headers example above - not as a th, which would claim to head something, and not by leaving it out, which shifts every header in that row one column left.
Never omit a cell. If a row has no value for a column, write an empty td or one containing a dash. Leaving the cell out does not leave a gap: the remaining cells shift left, and every value after it is filed under the wrong header. The table still renders, so nothing says it has happened.
<table>
<caption>Attendance</caption>
<tbody>
<tr>
<td></td>
<th scope="col">Monday</th>
<th scope="col">Tuesday</th>
</tr>
<tr>
<th scope="row">Amina</th>
<td>Present</td>
<td>-</td>
</tr>
</tbody>
</table>
Open this example in the editor and change it. Nothing you type is saved.
Practice task
Build a table of your own data - books read this year, expenses by month, anything with rows and columns - with a caption, a thead, and scope on every header cell. Then turn on your operating system's screen reader and listen to one data cell. Remove the scope attributes and listen again. That difference is the whole lesson.