CSE 134B Web Client Languages
  1. Structure
  2. HTML
  3. Tables

Lesson 07 · Semantics

Tables

A table is the one HTML structure with two dimensions. Every cell sits where a row meets a column, and the markup can say which header names each of them, so a reader who cannot see the grid can still ask which column a value is in and get an answer. For a decade tables were also the web's layout engine, and that misuse still shapes how people think about them. This lesson separates the two jobs, then teaches the data table properly: its elements, its headers, its grid, its styling and the narrow screen.

Learning objectives

  • Decide whether content is tabular, and choose a table, a list or a description list accordingly
  • Explain why layout tables failed, and what role="presentation" does and does not fix
  • Mark up a data table with caption, thead, tbody, tfoot, and header cells whose scope is explicit
  • Use headers and id, or scope="colgroup" and "rowgroup", when spans make a table complex, and know when to split it instead
  • Replace the obsolete presentational attributes with CSS, and choose a responsive strategy that keeps the table a table

What makes data tabular

The HTML standard says the table element "represents data with more than one dimension, in the form of a table." That is the whole test. Data is tabular when each value means something only at the intersection of two things: a row that says which record it belongs to, and a column that says which property it is. The number 540 in a trail report means nothing alone. It means "Canyon Rim has 540 metres of elevation gain" because it sits in the Canyon Rim row and the elevation column.

If you would reach for a spreadsheet to hold the data, it is probably a table. A timetable, a comparison of three phone plans, a grade book: each is read in two directions, down a column to compare one property across records, and along a row to see one record whole.

A list of steps has one dimension; a glossary pairs terms with definitions, side by side but not a grid. The question is not whether it should look like a grid but whether the data has rows and columns. CSS can make anything look like a grid; only the table says that two dimensions exist.

A sighted reader gets a cell's context by glancing at the top and the left edge. The markup is the only way to give it to someone who cannot glance.

Tables for layout: a short history

Before CSS could position anything, tables were the only HTML structure that put things side by side. Tables arrived in browsers in the mid-1990s and were standardized in HTML 3.2 in 1997, and designers saw what they could do: a page with a header, a sidebar and a footer was a table with a cell spanning the top row, another spanning the bottom, and two in between.

Spacer GIFs and nested tables

Tables gave columns, but not precise spacing, so designers added the spacer GIF: a transparent image one pixel square, stretched with width and height attributes to push content exactly where it should go. David Siegel's 1996 book Creating Killer Web Sites popularized the trick, along with layouts built from tables nested inside table cells.Siegel later called the single-pixel GIF an "old bandage" that added server load and made pages harder to edit, and recommended against it once CSS could do the job.A typical commercial page of 1999 was a table holding tables holding tables, with bgcolor, width, cellpadding and border="0" on nearly every element and a <font> around every run of text. Lesson 01 tells the story of that swing toward presentational markup; tables were its main tool.

Frames, the other layout trick

The other pre-CSS layout tool was the frame. Netscape Navigator 2.0, in 1996, let a <frameset> divide the window into panes, each a <frame> holding a separate document, so the navigation could stay put while the content pane changed. The cost was the web's basic promise that one URL names one thing. The address bar showed the frameset, not the page inside it, so bookmarks, shared links and the Back button pointed at the wrong state. HTML5 dropped frameset and frame; the current standard calls them "entirely obsolete," to be replaced by "iframe and CSS" or by complete pages generated on the server. The <iframe>, a frame inside an ordinary page, survived for embedded maps, videos and payment forms.

Why layout tables fail

The course deck gives three reasons the industry abandoned layout tables: they described presentation instead of content, they were terrible for accessibility, and they made responsive design far harder. All three come from one fact: a table says "this is a grid of related data," and a layout is not.

  • Reading order is source order. A screen reader, a search engine, a reader mode and a narrow screen all take content in the order of the tree, which for a table is row by row, left to right. A layout that lines up each sidebar link with a paragraph of the story reads aloud as link, paragraph, link, paragraph. The Web Content Accessibility Guidelines name this exact failure: F49, "using an HTML layout table that does not make sense when linearized," fails Success Criterion 1.3.2, Meaningful Sequence.
  • The table cannot reflow. A table's columns stay side by side however narrow the screen gets, so a phone must shrink the page or scroll it sideways.
  • The semantics announce the wrong thing. If a browser decides a layout table is a data table, a screen reader announces "table, 3 columns, 5 rows" and offers table navigation for content that is not a table. Browsers guess to soften this; the standard lists heuristics and admits it "does not define a precise heuristic." Chrome often treats a table with no th and no caption as a layout table in its accessibility tree (it also weighs borders, size and other clues), and screen readers such as NVDA can be set to skip those. Your page's meaning should not depend on a guess.
How a layout table linearizesOn the left, a layout table: a header cell across the top, then three rows that each pair a sidebar link with a piece of the story, then a footer cell. Numbers mark the source order, row by row: 1 club name, 2 Home, 3 headline, 4 Trail maps, 5 first paragraph, 6 Join the club, 7 second paragraph, 8 footer. On the right, the sequence a screen reader hears, with the navigation links interleaved between the parts of the story.What the eye sees1 · Ridgeline Hiking Club2 · Home3 · Autumn trail report4 · Trail maps5 · Most trails are open…6 · Join7 · Bring water…8 · Updated 25 OctoberWhat a screen reader hears1 Ridgeline Hiking Club2 Home3 Autumn trail report4 Trail maps5 Most trails are open…6 Join the club7 Bring water…8 Updated 25 October
A layout table is read row by row. Lining up each sidebar link with a piece of the story looks tidy on screen and interleaves navigation with content for anyone who reads in source order, which includes a phone that stacks the cells.

The escape hatch: role="presentation"

The one place layout tables survive is HTML email, where mail clients support so little CSS that tables are still the most reliable way to get columns. There the accepted fix is role="presentation" (or its synonym role="none") on the table. It removes the table's semantics, and because tr, td and th exist only to be parts of a table, theirs go with it. Checked in Chrome, a table with role="presentation" appears in the accessibility tree as plain text, with no table, row or cell nodes at all.

The role fixes the third problem and neither of the others: the reading order is still the source order, and the table still will not reflow. A layout table must also contain nothing that belongs only to data tables (WCAG's failure F46 covers th, caption or summary in a layout table). On the web, outside email, there is no reason left to write one. CSS grid places regions in rows and columns without claiming they are data, and the source can stay in reading order while the grid puts each region wherever the design wants it; the CSS layout lesson teaches it. The demo below shows the same page both ways.

The table elements

A data table is built from ten elements. Three of them are containers for rows, two describe columns, and the rest are the table, its name, its rows and its cells. The standard fixes their order inside table: an optional caption first, then zero or more colgroup elements, an optional thead, then either zero or more tbody elements or one or more tr elements, and finally an optional tfoot.

The parts of a data tableA table drawn as a grid four columns wide. Across the top, the caption. Below it, the thead row of four column header cells, th with scope col. Then the tbody, three rows that each start with a row header cell, th with scope row, followed by three data cells, td. Then the tfoot, one row of totals. A dashed outline around the second column is labelled col: one per column, inside a colgroup, invisible but styleable. Brackets on the right name caption, thead, tbody and tfoot.caption: Trail conditions (sample data)th scope=colth scope=colth scope=colth scope=colth scope=rowtdtdtdth scope=rowtdtdtdth scope=rowtdtdtdth scope=rowtd (total)td (total)tdcol: one per column, inside a colgroup;invisible, but takes a few stylescaptiontheadtbodytfoot
Every part has a job. The caption names the table, the row groups say which rows are headings, body and totals, and each th says which way it labels: down its column or along its row.

table and caption

<table> wraps everything. Its first child, if present, must be <caption>: the table's name, announced by a screen reader as the user enters the table and read as a title by everyone else. If a visible heading just above the table already names it, point to the heading with aria-labelledby on the table instead of repeating the words. When a table is the only content of a figure, the standard says to drop the caption "in favor of the figcaption," so the table is not named twice.

Older tables carried a summary attribute, invisible text meant to explain the table's structure to screen reader users. It is obsolete. The standard says to explain a complex table in visible text, where everyone benefits, and adds that the best option "is to adjust the table such that no explanation is needed."

thead, tbody and tfoot

The three row groups divide the rows by role. thead holds the rows of column headers; tbody holds the data; tfoot holds totals or notes. A table may have several tbody elements when its data falls into sections, such as a league table with one body per division. The groups give CSS and scripts something to select and tell assistive technology which rows are headings.

Two details trip people up. First, tfoot goes last. HTML 4 required it before the tbody, and old tutorials still say so; the current standard allows it only after every tbody and tr. Second, the tbody is there even when you leave it out. If you write tr elements directly inside table, the parser inserts a tbody around them, so the tree has a tbody the source never mentioned and the selector table > tr matches nothing (Mistake 5 in lesson 01's malformed.html; lesson 04 lists it with the parser's other table repairs, foster parenting among them, in What the parser does with invalid markup). Write the tbody yourself and the source matches the tree.

tr, th and td

tr is a row; inside it, each cell is a th or a td. The difference is meaning, not looks. A td is a data cell. A th is a header cell: it labels other cells. Browsers show th bold and centred, which tempts authors to use it for anything bold, and a bold td for headers they want left-aligned. Choose the element for what the cell is, then style it.

colgroup and col

Rows are elements; columns are not. A column exists only as the cells that happen to line up. colgroup and col give columns a small presence in the markup: one col per column (or one with span="3" for three), grouped into colgroup elements that say which columns belong together. They render nothing themselves.

You can style a col only a little, because cells are not its children and inherit nothing from it. CSS 2.1 lists four properties that apply to columns: border (in the collapsing border model), background, width and visibility, whose value collapse hides a column without disturbing the rest of the table. Checked in Chrome, a col with color, padding and text-align changes none of its cells, while its width and background apply. For anything else, select the cells with :nth-child(). The more valuable job of colgroup is to define the column groups that scope="colgroup" refers to.

<table> <caption>Trail conditions, week of 19 October (sample data)</caption> <thead> <tr> <th scope="col">Trail</th> <th scope="col">Length (km)</th> <th scope="col">Status</th> </tr> </thead> <tbody> <tr><th scope="row">Ridge Loop</th><td>6.4</td><td>Open</td></tr> <tr><th scope="row">Canyon Rim</th><td>8.7</td><td>Closed, washout</td></tr> </tbody> <tfoot> <tr><th scope="row">2 trails</th><td>15.1</td><td>1 open</td></tr> </tfoot> </table>

Header cells and what they label

A th marks a header cell. It does not say what it heads. The standard defines an algorithm that assigns header cells to every data cell, and you can either tell it what you mean or let it guess.

scope

The scope attribute on a th tells it which way to look. It has four values:

  • col: the header applies to the cells below it in its column.
  • row: the header applies to the cells after it in its row.
  • colgroup: the header applies to every remaining cell in its column group, the columns a colgroup element defines.
  • rowgroup: the header applies to every remaining cell in its row group, the thead, tbody or tfoot it sits in.

Leave scope out, or misspell it, and the header falls into the auto state, where, in the standard's words, it "applies to a set of cells selected based on context." The context rule is narrow. An auto header is a column header only if no data cell shares its row, and a row header only if it is not a column header and no data cell shares its column. A single empty td in the top-left corner, which is how most tables fill that slot, is a data cell, and by the standard's rule it stops every header in the first column from counting as a row header.

Browsers do not stop at the standard's rule when they build the accessibility tree. Checked in Chrome, a table with an empty corner cell and no scope anywhere still exposes the left column as row headers. Other browsers guess differently, and screen readers add heuristics of their own. Writing scope on every th, even where the guess would come out right, replaces all those guesses with a statement.

Row headers are the ones most often forgotten. Authors mark up the header row, then write the first column as plain, perhaps bold, td cells. A screen reader user then hears every value with its column and without its row: "Length, 8.7," but 8.7 kilometres of which trail? If each row has a name, that name is a <th scope="row">.

What a screen reader announces

With headers in place, moving between cells sounds roughly like this. Entering the table, the screen reader reads its caption and size, something like "Trail conditions, table, 6 columns, 14 rows." Moving down the Elevation gain column from Ridge Loop to Creek Trail, it reads the new row header and the value: "Creek Trail, 45." Moving right into the Status column, it reads the new column header: "Status, Open." Screen readers typically announce only the header that changed. Treat that as an outline, not a transcript: screen readers differ in wording, order and repetition, and the same one can behave differently in different browsers. What does not differ is that without header cells, none of them has a header to read.

abbr on th

A long header such as "Elevation gain (m)" is clear once and tiresome on every cell. The abbr attribute on a th gives "an alternative label for the header cell, to be used when referencing the cell in other contexts": <th scope="col" abbr="Gain">Elevation gain (m)</th>. Treat it as a hint: screen reader support is reported to be uneven, and Chrome keeps the full text as the header's accessible name. It does no harm. (abbr on a td is obsolete.)

headers and id

When a table has more than one level of headers, scope can still do the job if the levels line up with column groups and row groups. When they do not, the headers attribute states the association outright. Give each th an id, then list, on each data cell, the ids of every header that applies to it:

<tr> <td rowspan="2"></td> <th id="fall" colspan="2">Fall 2026</th> <th id="winter" colspan="2">Winter 2027</th> </tr> <tr> <th id="fall-enr">Enrolled</th><th id="fall-wait">Waitlist</th> <th id="win-enr">Enrolled</th><th id="win-wait">Waitlist</th> </tr> … <tr> <th id="a00">A00</th> <td headers="fall fall-enr a00">212</td> <td headers="fall fall-wait a00">18</td> … </tr>

The standard requires each token in headers to be the id of a th in the same table. A cell with a headers attribute skips the scanning algorithm entirely: the list is the answer. That makes headers the most precise tool and the most fragile one. Every data cell carries a list, every list must be kept in step with every id, and a renamed id silently detaches a column. It also decides only the associations, not the roles: in the demo below, the headers version gives every cell the right headers, yet Chrome reports the row-group heading "Lectures" as a column header, because without scope="rowgroup" it has to guess that heading's role. The same table written with scope="colgroup" and scope="rowgroup" gets both right with no ids at all.

rowspan, colspan and the table grid

The course slides compare rowspan and colspan to "Merge Cells" in a spreadsheet, and the comparison is exact. colspan="2" makes a cell two columns wide; rowspan="3" makes it three rows tall. The standard caps colspan at 1000 and rowspan at 65534, and gives rowspan="0" a special meaning: span every remaining row in the row group.

To make sense of spans, the standard models every table as a grid of slots, one per row-and-column position, and each cell covers one or more slots. The browser builds the grid by walking the rows in order and placing each cell in the next free slot of its row. A cell with rowspan occupies slots in the rows below, so in the source the row after a rowspan="2" cell starts one cell short. Miscount and the grid gets ragged, with cells pushed into a column that has no header, or two cells claiming the same slot, which the standard calls a table model error.

Slots, spans, and the scan for header cellsA grid of five columns and four rows of slots. In the top-left corner an empty data cell covers two slots, one above the other, because of rowspan 2. Fall 2026 and Winter 2027 each cover two slots side by side, because of colspan 2. The second row has four column headers: Enrolled, Waitlist, Enrolled, Waitlist. The third and fourth rows start with the row headers A00 and B00, then four data cells each. The data cell 187, in row B00 under Fall Enrolled, is highlighted. One arrow runs left from it to B00; another runs up from it through Enrolled to Fall 2026. Notes on the right say the scan goes left along the row and up the column, collecting header cells, and that the scope or headers attribute decides which of them count.td, emptyrowspan=2Fall 2026 · colspan=2Winter 2027 · colspan=2EnrolledWaitlistEnrolledWaitlistA00212181984B00187020511Data cell 187 scans left along its row and up its column.Found: B00 (row), Enrolled (column), Fall 2026 (column group).scope says which finds count; headers replaces the scan with a list.Slotsone per row-and-columnposition; a spanning cellcovers severalSource rowsrow 2 has 4 cells, not 5:its first slot belongs to thecorner cell above
The browser places every cell on a grid of slots, then finds each data cell's headers by scanning left and up from it. Spans are what make that scan interesting: a header covering two slots labels both columns below it.

Spans are where header associations get hard. The scan copes with regular cases; scope="colgroup" and "rowgroup" handle headers that label a whole group; headers handles the rest. Each is harder to write, harder to check and less consistently announced than a plain table with one row of column headers and one column of row headers.

So the best fix for a complex table is often two simple tables: one per year, each with its own caption, instead of a header row for each year; one tbody or one table per region, instead of a first column that groups rows with rowspan. If each move from cell to cell needs three headers to make sense, the table is doing too much.

From presentational attributes to CSS

The layout-table era left tables covered in attributes that set their look. Every one of them is obsolete: the standard lists them under features that "must not be used by authors," and the Nu HTML Checker reports each as an error. Browsers still honour them, and you will meet them in email templates and legacy code. Each has a CSS replacement.

Obsolete table attributes and their CSS replacements
Obsolete attributeCSS instead
border="1"border on table, th and td; often only border-block-end on cells
cellpaddingpadding on th and td
cellspacingborder-spacing on table, or border-collapse: collapse for none
align, valigntext-align and vertical-align on cells; margin-inline: auto to centre a table
bgcolor, backgroundbackground-color, background-image
width, heightinline-size, block-size; table-layout: fixed for predictable columns
nowrapwhite-space: nowrap
frame, rulesBorders on the table, the row groups or the cells
summaryNot CSS: visible text in the caption or near the table

A handful of CSS properties exist only for tables. border-collapse chooses between two border models: separate, the default, where every cell has its own border with border-spacing between them, and collapse, where neighbouring cells share one border and border-spacing does nothing. caption-side moves the caption below the table with bottom, and table-layout: fixed sizes columns from the first row alone, so one long word cannot widen a column.

Two more properties matter for data. Numbers compare best when their digits line up, so align numeric columns to the end edge and turn on tabular figures with font-variant-numeric: tabular-nums, which gives every digit the same width. And a long table keeps its context if the header row stays in view, which position: sticky does on the thead cells.

@layer components { table { border-collapse: separate; border-spacing: 0; font-variant-numeric: tabular-nums; } caption { caption-side: top; text-align: start; font-weight: 600; } th, td { padding: 0.4rem 0.75rem; border-block-end: 1px solid var(--rule); text-align: start; vertical-align: top; } /* In the demo's table, columns 2 and 3 hold numbers: line up the digits. */ :is(th, td):nth-child(2), :is(th, td):nth-child(3) { text-align: end; } /* The header row stays in view while the body scrolls. */ thead th { position: sticky; inset-block-start: 0; background: var(--surface); } }

A sticky header sticks within its nearest scrolling ancestor, so inside a scroll container it sticks to that box, not the window (the CSS layout lesson explains why), and it needs a background, or the rows show through as they scroll under it. The sample uses border-collapse: separate with zero spacing because, in the collapsed model, the borders belong to the table rather than the cells, and some browsers leave them behind when a sticky cell moves.

The responsive table problem

A table with six columns needs room, and a phone held upright does not have it. The markup should not change with the viewport: the data has two dimensions whatever the screen size. The problem is presentation, and it has three honest answers, each with a cost.

Scroll the table in a focusable box

The simplest answer keeps the table whole and lets it scroll sideways inside a container with overflow-x: auto. The table stays a table, every column keeps its header, and a sticky first column can keep the row headers in view while the rest slides under them. Two details make it work.

First, a keyboard user has to be able to reach it. Scrolling with the arrow keys works only when the scrolling box, or something inside it, has focus, and a table of plain text has nothing focusable inside. Firefox, and Chrome since version 130, make a scroll container with no focusable content keyboard-focusable on its own, but Safari is reported not to, so tabindex="0" on the container remains the portable fix (lesson 03 covers tabindex).

Second, anything focusable needs a name and a role, or a screen reader announces a nameless stop in the tab order. A figure labelled by the table's caption does it, and so does a section with an accessible name, which becomes a region landmark (lesson 05 explains landmarks; use that only if the table deserves to be one). Add a visible focus style so sighted keyboard users can see where they are.

<figure tabindex="0" aria-labelledby="trails-caption"> <table> <caption id="trails-caption">Trail conditions (sample data)</caption> … </table> </figure> figure { max-inline-size: 100%; max-block-size: 22rem; overflow: auto; &:focus-visible { outline: 0.2rem solid var(--accent); } } tbody th { position: sticky; inset-inline-start: 0; background: var(--surface); }

Reflow the rows into cards

The second answer restyles each row as a card below some width, with each cell on its own line and its column name printed before it. It reads well on a phone for record-like data such as contacts or orders, and loses the thing tables are best at: comparing one column down many rows.

@media (width < 36rem) { table, tbody, tr, th, td { display: block; } thead { position: absolute; inline-size: 1px; block-size: 1px; overflow: hidden; clip-path: inset(50%); } td::before { content: attr(data-label) ": "; font-weight: 600; } }

This pattern has an accessibility history. Changing display on table elements used to strip their table semantics: a table set to display: block or grid stopped being announced as a table. Adrian Roselli tracked the bug for years: Chrome fixed it in version 80 in 2020, Firefox closed its last bug at the end of 2022, and Safari followed in Safari 17 in 2023. Checked in current Chrome, a table whose every part is set to display: block keeps its table, row, header and cell roles. One value is still a problem: display: contents on a table can remove it from the accessibility tree, so avoid it there. If you must support older browsers, ARIA's table, row, columnheader, rowheader and cell roles can put the semantics back, at the cost of markup that repeats what HTML already says.

The pattern has two smaller costs. The column name now exists twice, in the header cell and in each data-label, and the two can drift apart. And generated content from ::before is usually read by screen readers, so a user may hear "Status: Status, Open." Hide the header row with the visually-hidden technique shown, never with display: none, which removes the headers from the accessibility tree and leaves every cell unlabelled.

Show fewer columns

The third answer is editorial. Does the phone reader need all six columns? A schedule might show time, course and room, with the full table one tap away. Hiding columns with CSS quietly withholds data from the people with the smallest screens; designing a smaller table for them, on purpose, does not. Often the answer combines the first and third: fewer, better-chosen columns, in a box that scrolls when even those do not fit.

When a table is the wrong choice

The mirror image of the layout table is the table used for data that is not tabular. A table with one column and no headers, used because someone wanted a stack of bordered boxes, is a list wearing a costume; a screen reader announces a table, offers table navigation, and finds nothing to navigate. Use a ul or ol. Pairs of names and values, such as the specifications of one product, are a description list, dl, which lesson 06 covers with the other lists. If a design really is a stack of cards, a dl per record may serve better than a table restyled to look like one.

The opposite mistake happens too: tabular data built from a grid of div elements, so CSS can lay it out freely. No screen reader can say which column a value sits in, because no column exists, and modern CSS styles a real table almost any way you like.

Sortable tables, in outline

A sortable table puts a button inside each sortable th, so the control is focusable and announced as a button, and marks the column currently sorted with aria-sort="ascending" or "descending" on that th, so a screen reader can say how the data is ordered. Only one header should carry aria-sort at a time. Sorting itself means reordering the rows in the DOM with JavaScript, never reordering them visually with CSS order, which would leave the reading order behind; the DOM section teaches the scripting.

Try it: one table, three readers

The data-table demo reads one table three ways: as designed, with no CSS, and on a narrow screen. Work through all three, then build a table of your own.

  1. Open data-table.html and choose "No CSS." List everything you can still tell about the data without any styling: its name, which cells are headers, where the totals are. Each item on your list is something the markup carries.
  2. In the same demo, open DevTools and find the Accessibility pane in the Elements panel. Select a number in the body and note its role. Then select a trail name and note that it is a row header, not a cell. Delete scope="row" from that th in the Elements panel, check whether the role changes, and explain the result with the auto rule.
  3. Open complex-table.html. Before switching to "No scope (auto)," predict which headers each cell will lose. Then explain, using the auto rule in this lesson, why "Lectures" survives.
  4. Pick a table you use, such as a class schedule or a sports league table, and mark it up from scratch: a caption, the three row groups, scope on every header, numbers aligned with tabular figures, inside a focusable, named scroll container. Run it through the Nu HTML Checker, then use only the keyboard to scroll it at a narrow width.

With a screen reader (VoiceOver on a Mac, NVDA on Windows), move around your table with its table commands, then remove your row headers and listen again.

Summary

ConceptKey points
Tabular dataValues that mean something only where a row meets a column. If it has rows and columns, it is a table; if one dimension, a list; if name-value pairs, a dl.
Layout tablesSpacer GIFs and nested tables before CSS; frames alongside. They fail on reading order (WCAG F49), reflow and semantics. role="presentation" fixes only the semantics; use it in email, and CSS grid on the web.
The elementscaption first, as the table's name; colgroup; thead; tbody (inserted by the parser if omitted); tfoot last. tr rows of th header cells and td data cells.
colDefines columns and column groups. Takes only border, background, width and visibility.
scopecol, row, colgroup, rowgroup. Without it, the auto state guesses, narrowly in the standard and differently in each browser. Write it on every th, and do not forget row headers.
headers and abbrheaders lists header ids on a cell and replaces the scan: precise and fragile. abbr offers a short header label; support varies.
Spanscolspan and rowspan merge cells over a grid of slots. They complicate associations; splitting into simpler tables is often better.
StylingThe presentational attributes are obsolete. Use border-collapse, border-spacing, padding, text-align, caption-side, tabular-nums and sticky headers.
Narrow screensScroll in a focusable (tabindex="0"), named container; or reflow rows into cards, keeping headers in the accessibility tree; or show fewer columns. Changing display is safe in current browsers except contents.
InteractivityA button in each sortable th, aria-sort on the sorted one, rows reordered in the DOM by script.

Next steps

Tables are the last of the structures that hold content still. Lesson 08, Interactive elements, turns to the elements that do something without a line of script: details and summary, dialog, popover, meter and progress. The CSS that lays out everything that is not a table is in CSS layout, and the question of who can use any of it runs through Accessibility.