007: Tables
Learning outcomes
By the end of this lesson, you can decide whether information is genuinely tabular, create a table with caption, row groups, rows, headers, and cells, associate simple row and column headers with scope, and explain why tables are not page-layout tools.
Prerequisites and retrieval
Open the semantic portfolio. Identify one collection that is a list and one that has values compared across two dimensions. Recall WCAG's idea that visible information and relationships should also be programmatically available.
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
A value such as “09:00” has little meaning alone. In a schedule, its column header might be “Start” and row header “Monday.” Table structure lets software derive: Monday, Start, 09:00. Visual alignment alone cannot reliably communicate that association to a screen reader or alternative presentation.
Use a table when users need to compare values by both row and column. Use a list for a one-dimensional sequence such as three skills. Use headings and sections for page layout. Do not place the portfolio header in one cell and main content in another; CSS will later create columns without corrupting 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 is the table's own name and, when present, must be the first element child of table. Any colgroup elements come after the caption and before row groups such as thead and tbody. Do not replace the caption with a heading merely because both can look like titles; a nearby heading can introduce a section, while caption identifies the table.
When a table needs column metadata, the element order looks like this:
<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 still comes first; colgroup does not precede it. A colgroup describes one or more columns and can provide styling hooks, but it does not label those columns for users. The th cells in thead still carry the column-header meaning. Text nodes containing indentation whitespace do not change the element-child ordering requirement, but placing another element such as a heading or paragraph before caption does. Keep any section introduction outside the table, then begin the table itself with its caption.
Validate this order whenever you add column styling; visual success does not prove that the table children are correctly sequenced.
thead, tbody, and tfoot group rows semantically and assist styling/printing. They do not create headers by themselves; use th. A table can be valid without every group, but explicit groups make substantial tables clearer.
Use scope="col" for column headers and scope="row" for row headers in simple tables. Native header associations allow assistive technology to announce context. Empty cells can be legitimate when data is unavailable, but communicate meaning such as “Not scheduled” rather than leaving ambiguity.
Decide whether the content is actually tabular
Use a table when readers need to understand relationships across rows and columns. Do not use tables to create page layout, columns, cards, or spacing.
A useful test is: if you read one cell, do its row and column headers help explain what the value means? If yes, the content is probably tabular. If the content is simply a sequence of labeled facts, a description list may be clearer.
For complex data tables, scope is usually enough when headers have a simple row/column relationship. When a cell depends on multiple non-obvious headers, explicit id values on header cells and a space-separated headers attribute on data cells can make the relationship unambiguous:
<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 add headers everywhere by default; complexity itself can become a maintenance risk. Prefer a simpler table structure when you can.
Guided example: portfolio availability table
Add a 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 with coordinates. “Accessible tables” is Wednesday's Topic. “90 minutes” is Wednesday's Duration. The total label spans three columns with colspan="3"; spanning increases complexity, so use it only where the relationship remains clear.
Do not use obsolete presentational attributes such as border, cellpadding, cellspacing, align, or bgcolor. They mix presentation into markup; use CSS later. Do not use the obsolete summary attribute. For a complex table, provide visible explanatory text and stronger header associations.
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 real text, not meaning conveyed only by a green cell. A future visual icon must retain a text alternative. If each project needs paragraphs, images, and links rather than comparable scalar values, project article cards are more suitable. A table is not automatically right merely because data can be forced into rows.
Try keyboard and screen-reader table navigation if available. Header announcements should make each data cell understandable. Zoom to 200% and narrow the viewport. HTML cannot alone solve wide-table presentation; preserve data and header associations, then handle overflow/reflow in CSS without hiding essential columns.
Advanced optional extension: complex headers
Tables with multi-level or irregular headers may require colgroup/rowgroup scope or explicit id and headers associations. Example concept:
<th id="size" scope="col">Transfer size</th>
...
<td headers="profile size">180 KB</td>
Do not add headers everywhere to a simple table; scope is clearer. Before building a complex table, ask whether splitting it into two simple tables improves comprehension. Validate spans because one wrong rowspan or colspan can shift the entire cell grid.
To review an unfamiliar table, first ignore its borders and colors. Write the question answered by each axis, then pick one data cell and state every header needed to understand it. Repeat for a cell near each edge and any spanning header. If the sentence is ambiguous, the markup or the data model needs revision. This manual coordinate test catches semantic problems that a conformance checker cannot infer. It also helps decide whether a prominent cell is truly a header or merely a highlighted result. After the relationships work in plain HTML, CSS may improve scanning without becoming the only source of meaning.
Common mistakes and debugging
- Table for layout: ask whether row/column headers meaningfully describe each cell.
tdused for headers: usethso the relationship is programmatic.- No caption: add a concise table identifier.
theadassumed to make headers: cells still needth.- All cells marked
th: distinguish labels from values. - Incorrect scope: column headings use
col, row headings userow. - Mismatched cell counts or spans: draw the grid and count occupied columns.
- 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 satisfy an important part of WCAG Info and Relationships. Captions help users identify and choose among tables. Keep tables as simple as the data allows, and test associations manually; a validator cannot decide whether “Monday” should be a header.
Do not publish sensitive schedules or personal metrics merely to demonstrate a table. Escape or safely render untrusted data when a later server generates cells; HTML structure does not prevent injection. Large tables increase DOM and cognitive load. Paginate or summarize genuinely large datasets at the application layer, while retaining accessible access to complete information.
Tiered exercises
Level 1: choose
Decide whether skills, a weekly schedule, an article, and a product comparison should be a list, table, article, or table. Explain the dimensions.
Level 2: build
Create a course schedule with caption, thead, tbody, optional tfoot, at least three rows, and row/column scopes.
Level 3: audit
Build the project comparison, check each value's headers, test narrow viewport and zoom, validate spans, and remove all presentational attributes.
Level 1: skills are usually a list; schedule is a table because day/topic/time intersect; article is an article; comparable products are a table if users compare the same attributes across products.
Level 2: the guided availability table is complete. Each column has 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 reads “Weather summary, Validated, In progress.” It has four logical columns in every body row, no obsolete attributes, textual status, and a caption. Narrow-screen visual overflow remains a CSS task; do not destroy semantics to force fit.
Recap and exit questions
Tables represent two-dimensional relationships, not layouts. Captions identify tables, header cells label coordinates, scopes define simple direction, and row groups organize structure.
- 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
Run the "is this genuinely tabular?" test on a decision you will actually face: Rina wants her opening hours and her price list on the same page, and only one of them is a table.
Look at the two sets of data she gave you:
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 is one dimension (item → price), so it stays a list, not a table:
<h2>Today's prices</h2>
<ul>
<li>Sourdough loaf — €6.50</li>
<li>Croissant — €3.20</li>
<li>Cardamom bun — €2.80</li>
</ul>
Opening hours genuinely compare two dimensions once you add a second column Rina asked for — which oven is running that day — so it becomes 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>
Notice the Sunday row: colspan="2" merges "Hours" and "Oven" into one cell because both are simultaneously "closed" — a case the lesson's schedule example didn't need but yours does. That is normal; real data rarely matches a textbook case exactly, and adapting the pattern is the actual skill.
Further reading: MDN — Table accessibility covers spanning cells and multi-level headers in more depth than today's lesson.
