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 whosescopeis explicit - Use
headersandid, orscope="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.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
thand nocaptionas 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.
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.
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.
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 acolgroupelement defines.rowgroup: the header applies to every remaining cell in its row group, thethead,tbodyortfootit 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:
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.
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 attribute | CSS instead |
|---|---|
border="1" | border on table, th and td; often only border-block-end on cells |
cellpadding | padding on th and td |
cellspacing | border-spacing on table, or border-collapse: collapse for none |
align, valign | text-align and vertical-align on cells; margin-inline: auto to centre a table |
bgcolor, background | background-color, background-image |
width, height | inline-size, block-size; table-layout: fixed for predictable columns |
nowrap | white-space: nowrap |
frame, rules | Borders on the table, the row groups or the cells |
summary | Not 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.
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.
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.
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.
- 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.
- 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 thatthin the Elements panel, check whether the role changes, and explain the result with the auto rule. - 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.
- 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,
scopeon 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
| Concept | Key points |
|---|---|
| Tabular data | Values 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 tables | Spacer 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 elements | caption 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. |
col | Defines columns and column groups. Takes only border, background, width and visibility. |
scope | col, 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 abbr | headers lists header ids on a cell and replaces the scan: precise and fragile. abbr offers a short header label; support varies. |
| Spans | colspan and rowspan merge cells over a grid of slots. They complicate associations; splitting into simpler tables is often better. |
| Styling | The presentational attributes are obsolete. Use border-collapse, border-spacing, padding, text-align, caption-side, tabular-nums and sticky headers. |
| Narrow screens | Scroll 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. |
| Interactivity | A 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.
