Web Platform · HTML and Semantics
The Table Model, Foster Parenting, and Header Association
Loose table markup is rebuilt into thead, tbody, and tr by the parser, stray content is moved out, and the header association algorithm decides which cell describes which.
Writing <table><tr><td>a<td>b</table> produces a table, a tbody, a tr, and two cells. The tbody was never written. It exists because the tree builder inserts one when a row appears directly inside a table, and every selector, every stylesheet, and every accessibility mapping afterwards sees the element that the parser synthesized.
Tables are the part of HTML where the parser does the most repair work, because the structure is stricter than the syntax. A table has a fixed child sequence, cells belong to rows, and rows belong to sections. The parser enforces the nesting by inserting the missing elements and by moving most misplaced content out of the table entirely.
The permitted children and the implied ones
The content model for table is a caption first, then any number of colgroup elements, then an optional thead, then either one or more tbody elements or rows directly, then an optional tfoot. The parser implements that sequence through four insertion modes — in table, in table body, in row, and in cell — with in caption and in column group for those constructs.
Implied elements come from three rules. A tr token while the current node is a table, a tbody, a thead, or a tfoot closes any open row and inserts a tbody if none of those section elements is current. A td or th token outside any row inserts a tr. A col token outside a colgroup inserts a colgroup. The result is that the tree gains the structure the content model describes even when the source omitted it.
The reverse also happens. A tbody start tag while a tbody is open closes the first one and opens the second, which is why two consecutive tbody elements in the source produce two sections rather than an error. Missing structure is synthesized, but placement is not repaired: a tfoot written before a tbody stays where it was written, and the serialized tree preserves the source order even though the content model asks for the footer last.
Where stray content goes
Content that is not permitted inside a table is not discarded. It is foster parented: inserted immediately before the table element in the table’s parent, in the order it appeared. The rule applies in in table mode and covers most elements, including div, p, headings, and section.
Character tokens are handled differently, and the difference is the source of a common surprise. Text inside a table is buffered by the in table text mode until a non-character token arrives. If the buffered text is entirely whitespace, it is inserted into the table as normal. If it contains any non-whitespace character, the entire buffer, whitespace included, is foster parented and inserted before the table. A stray newline inside a table after an element is harmless; a single word beside a cell ejects all the text that was pending.
form is a special case in two directions. A form start tag inside a table is inserted and then immediately popped from the stack of open elements, so the form element exists in the tree with no children and any subsequent controls are attached to the table or fostered out. The form attribute on a control, which associates it with a form by ID without nesting, is the robust mechanism for controls inside a table. input type="hidden" is one of the few elements the parser accepts directly in in table mode, alongside script, style, and template.
The repair is not visible in the rendered output when the table is a layout container, because the foster-parented nodes appear just above the table and often occupy the same visual space. It becomes visible as soon as the table has a border or a background, at which point content that was meant to be inside appears outside, or the table collapses to its minimum height because its only children are empty rows.
The grid and overlapping spans
Header association works on a grid derived from the rows and cells, not on the element tree. Each row is one grid row. Each cell occupies colspan consecutive columns and rowspan consecutive rows, with a missing value meaning one. Cells are placed in the first free slot scanning left to right, and a cell that would overlap an existing slot is placed in the next free one.
Two limits are enforced by the table model. A colspan greater than 1000 is clamped to 1000; a rowspan greater than 65534 is clamped to 65534. rowspan="0" means the cell spans to the end of its row group, which the algorithm resolves once the group’s rows are known. The span attribute on col and colgroup is clamped to 1000 as well. Those numbers are upper bounds on work rather than realistic values; their purpose is to keep a hostile document from making the layout algorithm allocate without limit.
Spanning creates two failure modes that authors rarely anticipate. A cell whose rowspan extends past the last row of its section is truncated at the section boundary, so a header intended to cover a spanning group may end up covering only part of it. And the grid position of a cell is not its DOM index: reading cellIndex gives the position among the row’s cells, while the column it occupies depends on the spans of every cell before it, which is why code that computes column positions by summing colspan values has to account for cells from earlier rows whose spans reach the current one.
Choosing headers by scope
A th element is a header; which cells it describes is determined by the header association algorithm. The first check is the headers attribute. When a cell has one, the value is a list of IDs, and those elements are its headers — the algorithm does not consult position or scope at all. That makes headers the only fully explicit mechanism, and it is the one that works for tables with several independent header axes.
Without headers, the scope attribute decides. scope="col" associates the header with the cells in the same column below it. scope="row" associates it with the cells to its right in the same row. scope="colgroup" and scope="rowgroup" associate it with every cell in its column group or row group respectively. The four values cover the common table shapes: a header row across the top, a header column down the left, and the combination used for two-variable tables where the corner cell is empty.
When scope is absent, position decides. A header in the first row of its section is treated as a column header for the cells below it, and a header in the first column is treated as a row header for the cells to its right. That inference is what makes a plain table work without any attributes, and it is also what breaks when a table has a title row spanning several columns, a second header row, or a left-hand column that is data rather than labels.
<table>
<caption>Storage used by region</caption>
<thead>
<tr>
<th scope="col">Region</th>
<th scope="col">Objects</th>
<th scope="col">Bytes</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">Frankfurt</th>
<td>1,204</td>
<td>8.4 TB</td>
</tr>
</tbody>
</table>
In that table the caption supplies the accessible name, the three th elements in the head map to columnheader, and the row header maps to rowheader. Without the scope attributes the mapping would usually come out the same, because the positions are ordinary; the attributes remove the inference.
Captions, groups, and the mapping to the accessibility tree
The caption element must be the first child of the table. It supplies the accessible name of the table, which is why a table with a caption is announced before its contents and a table without one is announced as an unnamed table. A caption written after the first row is still attached to the table and still supplies its accessible name, but the position is non-conforming.
colgroup and col do not affect header association directly, but they do define column groups for scope="colgroup" and they are the only way to apply presentational properties to a column without touching every cell. Layout algorithms use the column widths they declare as hints, and the parser keeps them as children of the table even when col elements appear without a wrapping group.
The accessibility tree mapping follows the elements. table maps to table, thead, tbody, and tfoot to rowgroup, tr to row, td to cell, and th to columnheader or rowheader according to the association rules above. A table used purely for layout should carry role="presentation" on the table element, which removes the table semantics from the accessibility tree and leaves the cells as containers; without it, assistive technology announces a table with a row count and column count that describe the layout rather than any data.
Where the model stops being enough
Tables with two header axes, merged cells, and nested groupings push past what scope can express. A header that applies to a block of rows above a subtotal, a column header that changes meaning halfway down, or a cell that belongs to two different header groups all require headers with explicit IDs. The cost is maintenance: every ID has to be unique in the document, and a column inserted by a script has to update the lists that reference it.
The alternative that many data-heavy interfaces take is to stop using table for the grid and build it from div elements with ARIA roles. That reconstructs the table semantics by hand, including the row and column counts, the header association, and the keyboard navigation that a native table gets for free. The HTML model does less, but the parts it does are implemented by the browser and exposed consistently, which is the reason to stay inside it until the structure genuinely cannot be expressed.
The naming rules that make a caption a name and a legend a name are the same rules that apply to every other element: Accessible Name Computation: Which Attribute Wins.
References
- HTML Standard — Tabular data — WHATWG
- HTML Standard — Tree construction — WHATWG
- WAI Tutorials — Tables — W3C Web Accessibility Initiative