HTML Tables Table
| Element | Role | Field note |
|---|---|---|
<table> | The grid container | Never for layout - data with a logical relationship only |
<caption> | The table's name | First child; read first by screen readers - a visible title, not a skipped one |
<thead> | Column header block | Prints at the top of every page on long tables |
<tbody> | Data rows block | Implied if absent, but explicit beats implied for styling and scripting |
<tfoot> | Summary rows block | Totals live here - announced last, printed bottom |
<tr> | One row | Rows contain only th and td |
<th scope="col"> | Header cell | scope says which cells it names - col, row, or both |
<td> | Data cell | headers attribute pairs complex cells to their headers by id |
<colgroup> / <col> | Column styling channel | Style whole columns without touching every row |
colspan / rowspan | Cell spanning | Every row must still total the same column count - spreadsheets lie quietly here |
HTML tables are the one element family with a job nobody else can do: presenting two-dimensional data with named axes. The table below is the complete anatomy - ten elements from the container to cell-spaning attributes - with the accessibility and honesty rules attached to each.
Bottom line: two moves turn a grid of cells into an accessible table. A caption that states what the data shows (screen readers announce it before reading a single cell), and scope="col" or scope="row" on every header cell, which tells assistive technology which axis each header names. Without those, the same markup renders perfectly and reads as an unanchored stream of numbers.
The honest part: tables are for data with a logical relationship - not for layout. That fight ended two decades ago; flexbox and grid do layout now, and layout tables actively harm screen readers and mobile rendering. The remaining table discipline is arithmetic: every row must total the same column count after colspan and rowspan, and a spreadsheet export that ignores this quietly corrupts the grid.
How to use
- Build top-down: table, caption, thead with th scope="col", tbody rows of td, tfoot for totals.
- Style whole columns through colgroup and col instead of class-per-cell - one rule where ten would do.
- Check the arithmetic after any span: each row's colspans must sum to the same column count as the thead.
Frequently asked questions
What is the difference between thead, tbody and tfoot?
They group rows semantically: thead holds the column headers, tbody the data rows, tfoot the summary. Two practical payoffs beyond semantics: long tables print the thead at the top of every page and tfoot at the bottom automatically, and styling hooks attach to the blocks (striping tbody rows without touching headers). The browser implies a tbody if you omit it - but explicit beats implied for scripting and CSS targeting.
What does the scope attribute do on a th?
It declares which cells a header names: scope="col" for the column below, scope="row" for the row across. Screen readers use it to announce context - with scope, a cell reads as "March, revenue, 4,500"; without it, the same cell is just "4,500" floating in space. For the first cell of each row (a label column), scope="row" turns it into that row's name. Complex tables with multi-level headers go further with the headers/id pairing.
Are tables still okay to use for layout?
No - and the reason is practical, not purist. Layout tables force a linear reading order onto screen readers that mismatches the visual design, break on narrow screens (a table cannot reflow its rows into a column), and add markup weight that flexbox and grid replace with three lines of CSS. Tables present data with a logical two-dimensional relationship: schedules, prices, comparisons. If removing the borders would make the content meaningless, it is data - use a table.
How do colspan and rowspan work without breaking the grid?
colspan="3" makes one cell occupy three column slots in its row; rowspan="2" makes it occupy two rows. The arithmetic rule: every row - counted with spans - must total the same number of columns as the thead, and cells absorbed by a rowspan from above are simply not declared in the lower rows. The failure mode is silent: the browser does not error, it just renders a ragged grid, which is why generated tables should assert the column count per row before writing markup.