007: Tables
Learning outcomes
By the end of this lesson, you should be able to tell when information is genuinely tabular, build a table using caption, row groups, rows, headers, and cells, and connect simple row and column headers with scope. You should also be able to explain why a table describes data relationships rather than laying out a page.
Prerequisites and retrieval
Open the semantic portfolio and inspect its content. Find one collection that is simply a list, then find one set of values that makes more sense when compared across two dimensions. Also bring back WCAG's idea that relationships visible to a person should be available programmatically too. That principle is the reason table markup matters beyond getting text to line up on screen.
Terminology
- Tabular data: Data whose meaning depends on relationships across two dimensions of rows and columns. — Source: WHATWG: Tables
- Table: “The table element represents data with more than one dimension.” — Source: WHATWG: The table element
- Caption: “The caption element represents the title of the table.” — Source: WHATWG: The caption element
- Row: “The tr element represents a row of cells in a table.” — Source: WHATWG: The tr element
- Header cell: “The th element represents a header cell in a table.” — Source: WHATWG: The th element
- Data cell: “The td element represents a data cell in a table.” — Source: WHATWG: The td element
- Row group: thead, tbody, and tfoot group rows semantically and for styling/printing. — Source: WHATWG: The tbody element
- Scope: The scope attribute states whether a th applies to row, col, rowgroup, or colgroup. — Source: WHATWG: th scope attribute
- Layout table: Using table markup for visual positioning instead of data — harms accessibility and is obsolete practice. — Source: W3C WAI: Tables Tutorial
- Column group (
colgroup/col): "The colgroup element represents a group of one or more columns in the table." — Source: WHATWG: Tables — colgroup - Headers attribute (
headers): "The headers attribute specifies which header cells apply to a data cell." — Source: WHATWG: Tables — headers attribute - Spanning (
colspan/rowspan): "colspan/rowspan indicate how many columns/rows a cell spans." — Source: WHATWG: Tables
Mental model: coordinates need labels
Take a value like “09:00” on its own. It tells you very little. In a schedule, the column header might be “Start” and the row header might be “Monday”, giving software the complete coordinate: Monday, Start, 09:00. Alignment that looks obvious to a sighted reader is not a dependable way to provide that association to a screen reader or another presentation of the content.
Use a table when people need to compare values by both row and column. A one-dimensional sequence such as three skills belongs in a list. Page layout belongs to headings, sections, and, later, CSS. For example, do not put the portfolio header in one table cell and the main content in another just to create columns; CSS can create that visual arrangement without damaging the document's reading order.
Anatomy and reading order
<table>
<caption>Weekly study schedule</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Topic</th>
<th scope="col">Start</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Monday</th>
<td>Semantic HTML</td>
<td>09:00</td>
</tr>
</tbody>
</table>
caption gives the table its own name. When it is present, it must be the first element child of table. If the table also has colgroup elements, those come after the caption and before row groups such as thead and tbody. A nearby heading can introduce the surrounding section, but it does not replace the caption's job of identifying this particular table.
When a table needs metadata about its columns, the element order is still anchored by the caption:
<table>
<caption>Weekly study schedule</caption>
<colgroup>
<col class="day-column">
<col span="2" class="session-column">
</colgroup>
<thead>...</thead>
<tbody>...</tbody>
</table>
The caption remains first; colgroup does not go before it. A colgroup describes one or more columns and can offer styling hooks, but it does not label columns for users. The th cells in thead still provide the column-header meaning. Indentation whitespace is a text node and does not change the element-child requirement. An actual element, such as a heading or paragraph, before caption does. Put an introduction outside the table, then make the caption the table's first element.
This ordering is worth checking when you add column styling. A table can look correct while its children are semantically out of sequence.
thead, tbody, and tfoot group rows for semantic organization and for styling or printing. They do not turn cells into headers; you still need th. Every group is optional, but explicit groups make a substantial table easier to understand and maintain.
For a simple table, use scope="col" on column headers and scope="row" on row headers. These native associations give assistive technology the context needed to announce a data cell. An empty cell can be valid when data is unavailable, but silence is often ambiguous. Say what it means, such as “Not scheduled”.
Decide whether the content is actually tabular
The deciding factor is the relationship in the data, not the visual shape you want. Use a table when readers need to understand values across rows and columns. Do not use one to build page columns, cards, spacing, or other layout.
A practical test is this: if you read one cell, do its row and column headers help explain what the value means? If they do, the content is probably tabular. If the content is just a sequence of labeled facts, a description list may express it more clearly.
In a complex data table, scope is generally sufficient when the row and column relationships are straightforward. If a cell depends on several headers and the relationship is not obvious, give the header cells explicit id values and identify the applicable headers from the data cell with a space-separated headers attribute:
<table>
<tr>
<th id="project">Project</th>
<th id="status">Status</th>
</tr>
<tr>
<th id="alpha" headers="project">Alpha</th>
<td headers="alpha status">Complete</td>
</tr>
</table>
Do not reach for headers in every table. More explicit markup is not automatically better; unnecessary complexity creates another maintenance surface. Start with a simpler structure when it communicates the relationships clearly.
Guided example: portfolio availability table
Add this section to about.html:
<section>
<h2>Study availability</h2>
<p>Times use the local time zone.</p>
<table>
<caption>Available study sessions this week</caption>
<thead>
<tr>
<th scope="col">Day</th>
<th scope="col">Topic</th>
<th scope="col">Start</th>
<th scope="col">Duration</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Monday</th>
<td>Semantic structure</td>
<td>09:00</td>
<td>60 minutes</td>
</tr>
<tr>
<th scope="row">Wednesday</th>
<td>Accessible tables</td>
<td>14:00</td>
<td>90 minutes</td>
</tr>
<tr>
<th scope="row">Friday</th>
<td>Portfolio review</td>
<td>10:30</td>
<td>60 minutes</td>
</tr>
</tbody>
<tfoot>
<tr>
<th scope="row" colspan="3">Total planned time</th>
<td>210 minutes</td>
</tr>
</tfoot>
</table>
</section>
Read each body value as a coordinate rather than as an isolated string. “Accessible tables” is Wednesday's Topic, and “90 minutes” is Wednesday's Duration. The total label spans three columns with colspan="3". Spanning is useful when the relationship is still obvious, but it makes the grid harder to reason about, so apply it deliberately.
Leave obsolete presentational attributes such as border, cellpadding, cellspacing, align, and bgcolor out of new markup. They mix presentation with structure; CSS will handle presentation later. The obsolete summary attribute should not be used either. For a complex table, provide visible explanatory text and stronger header associations instead.
Intermediate example: project comparison
<table>
<caption>Portfolio project comparison</caption>
<thead>
<tr>
<th scope="col">Project</th>
<th scope="col">Pages</th>
<th scope="col">Images</th>
<th scope="col">Validated</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Profile site</th>
<td>3</td>
<td>2</td>
<td>Yes</td>
</tr>
<tr>
<th scope="row">Weather summary</th>
<td>1</td>
<td>1</td>
<td>In progress</td>
</tr>
</tbody>
</table>
“Yes” is meaningful text. It is not merely a green background that disappears when color is unavailable. If you later add an icon, keep a text alternative as well. Conversely, if every project needs paragraphs, images, and links, those are likely project article cards rather than comparable scalar values. The fact that data can be forced into rows does not make a table the right representation.
When possible, try keyboard and screen-reader table navigation. The announced headers should make each data cell understandable. Also zoom to 200% and narrow the viewport. HTML alone cannot solve the presentation of a wide table: keep the data and its associations intact, then use CSS for overflow or reflow without hiding essential columns.
Advanced optional extension: complex headers
Multi-level or irregular headers may need colgroup/rowgroup scope or explicit id and headers associations. Here is the central idea:
<th id="size" scope="col">Transfer size</th>
...
<td headers="profile size">180 KB</td>
For a simple table, scope is clearer than adding headers everywhere. Before building a complex table, ask whether two simpler tables would be easier to understand. Check every span carefully: one incorrect rowspan or colspan can shift the entire cell grid.
When reviewing an unfamiliar table, temporarily ignore its borders and colors. Write down the question answered by each axis. Then choose one data cell and state every header required to understand it. Repeat that test for a cell near each edge and for any cell beneath a spanning header. If the resulting sentence is ambiguous, the markup or the data model needs work. This manual coordinate test catches semantic problems that a conformance checker cannot infer, and it helps distinguish a real header from a merely highlighted result. Once the relationships are sound in plain HTML, CSS can make scanning easier without becoming the only source of meaning.
Common mistakes and debugging
- Table for layout: ask whether row and column headers meaningfully describe each cell.
tdused for headers: usethso the relationship is exposed programmatically.- No caption: add a concise identifier for the table.
theadassumed to make headers: the cells still need to bethelements.- All cells marked
th: separate labels from values. - Incorrect scope: column headings use
col; row headings userow. - Mismatched cell counts or spans: draw the grid and count the columns each row occupies.
- Obsolete visual attributes: remove them and defer presentation to CSS.
- Color-only status: include text such as “Passed” or “In progress”.
Accessibility, security, and performance
Correct table headers address an important part of WCAG's Info and Relationships requirement. Captions help people identify and choose among multiple tables. Keep the structure as simple as the data permits, and test the associations yourself; a validator cannot decide whether “Monday” is actually a header for the values below it.
Do not publish sensitive schedules or personal metrics just to demonstrate table markup. If a server later generates cells from untrusted data, escape or safely render that data; table structure does not prevent injection. Very large tables also impose DOM and cognitive costs. For genuinely large datasets, paginate or summarize at the application layer while keeping the complete information accessible.
Tiered exercises
Level 1: choose
Decide whether skills, a weekly schedule, an article, and a product comparison should use a list, table, article, or table. Explain the dimensions that led to each choice.
Level 2: build
Create a course schedule with a caption, thead, tbody, an optional tfoot, at least three rows, and both row and column scopes.
Level 3: audit
Build the project comparison. Check the headers for every value, test at a narrow viewport and at increased zoom, validate all spans, and remove every presentational attribute.
Level 1: skills are usually a list; a schedule is a table because day, topic, and time intersect; an article is an article; comparable products belong in a table when users are comparing the same attributes across products.
Level 2: the guided availability table is complete. Its columns use scope="col", each day is a row header with scope="row", and the caption identifies the table.
Level 3: the intermediate comparison is complete. A correct audit can read “Weather summary, Validated, In progress.” Every body row occupies four logical columns, there are no obsolete attributes, status is expressed as text, and the table has a caption. Narrow-screen overflow remains a CSS concern; do not damage the semantics just to force the table to fit.
Recap and exit questions
Tables describe two-dimensional relationships, not page layouts. A caption identifies the table, header cells label its coordinates, scope describes simple direction, and row groups organize the rows.
- What test tells you whether a table is appropriate?
- How do
thandtddiffer? - Does
theadcreate header associations by itself? - Why is
captionbetter than only a nearby paragraph? - When might a complex table be split?
Try it with your own example
Apply the “is this genuinely tabular?” test to a decision you will actually face: Rina wants her opening hours and her price list on the same page, but only one of them is a table.
Here are the two sets of data she provided:
Opening hours: Mon-Fri 07:00-18:00, Sat 08:00-14:00, Sun closed
Prices: Sourdough loaf 6.50, Croissant 3.20, Cardamom bun 2.80
The price list has one dimension, item to price, so it remains a list:
<h2>Today's prices</h2>
<ul>
<li>Sourdough loaf — €6.50</li>
<li>Croissant — €3.20</li>
<li>Cardamom bun — €2.80</li>
</ul>
The opening hours become genuinely two-dimensional when Rina adds the oven running that day as a second column. That is a real table:
<table>
<caption>Opening hours and oven schedule</caption>
<thead>
<tr><th scope="col">Day</th><th scope="col">Hours</th><th scope="col">Oven</th></tr>
</thead>
<tbody>
<tr><th scope="row">Monday–Friday</th><td>07:00–18:00</td><td>Both wood-fired ovens</td></tr>
<tr><th scope="row">Saturday</th><td>08:00–14:00</td><td>One oven</td></tr>
<tr><th scope="row">Sunday</th><td colspan="2">Closed</td></tr>
</tbody>
</table>
The Sunday row is the useful edge case. colspan="2" merges “Hours” and “Oven” because both are simultaneously “closed”, which the earlier schedule did not need. That is normal: real data rarely fits a textbook pattern exactly, and adapting the semantic pattern is the skill you are practicing.
Further reading: MDN — Table accessibility covers spanning cells and multi-level headers in more depth than today's lesson.
