Mert Özen Further With Every Line
Frontend HTML5 and Semantic HTML — #10

Tables and Tabular Data: caption, thead, th scope, colgroup and Responsive Tables

Mert Özen Nov 14, 2026 14 min 7 views
Tables and Tabular Data: caption, thead, th scope, colgroup and Responsive Tables

A data table gets its name from caption and its row and column relationships from th scope; on a narrow screen it goes into a scrollable wrapper with tabindex, role="region" and a name. In this post we read Bookstore's order history in the accessibility tree and measure that text-align on col does nothing, a headers/id bug, WebKit not focusing a plain wrapper, and how an Angular row using a host element instead of tr empties the table.

Version note: This post follows the November 2026 state of the HTML (HyperText Markup Language) Living Standard and Baseline; the examples were tested with Chrome 158, WebKit 27.2 (Playwright 1.65), axe-core 4.14, Nu Html Checker 26.11.9 and Angular 22.2 (TypeScript 6.0).

Guess First

In Bookstore's Angular application, every row of the order history table is a component. The developer who wrote the row gave it an element selector, like any other component:

import { Component, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Order } from './order';

@Component({
  selector: 'app-order-row',
  imports: [CurrencyPipe],
  template: `
    <th scope="row">#{{ order().id }}</th>
    <td>{{ order().date }}</td>
    <td>{{ order().total | currency: 'USD' }}</td>
  `,
})
export class OrderRowElement {
  readonly order = input.required<Order>();
}

In the account page's template this component is used in the table's tbody; right below it is a second version of the same table:

  <table id="orders-element">
    <caption>Orders (element selector)</caption>
    <thead>
      <tr><th scope="col">Order</th><th scope="col">Date</th><th scope="col">Total</th></tr>
    </thead>
    <tbody>
      @for (order of orders; track order.id) {
        <app-order-row [order]="order" />
      }
    </tbody>
  </table>

  <table id="orders-attribute">
    <caption>Orders (attribute selector)</caption>
    <thead>
      <tr><th scope="col">Order</th><th scope="col">Date</th><th scope="col">Total</th></tr>
    </thead>
    <tbody>
      @for (order of orders; track order.id) {
        <tr [appOrderRow]="order"></tr>
      }
    </tbody>
  </table>

Both tables look fine on screen, with aligned columns. For a screen reader user, how many rows does the first table have? Does the th scope="row" holding the order number work as a row header? What does the Nu Html Checker say? And what would change if we rendered this page on the server with Angular SSR (Server-Side Rendering)?

In the previous post, "The head and Meta Tags: charset, viewport, title, description, Open Graph, theme-color and link rels", we built the page's identity. In this post we return to the body and look at one of the most misused elements: table. A table is for data with row and column relationships; when a screen reader reaches a cell, it can say which row and column header it belongs to. That relationship comes for free when the markup is right, and silently disappears when it is wrong.

Order History: caption, thead, tbody, tfoot and th scope

First, the correct table in plain HTML. The order history on Bookstore's account page:

{{site/orders.html}}

A script that prints the table-related roles from Chromium's accessibility tree (the axtree.js from the series' "Semantic HTML and Landmarks" post, adapted for tables):

const { chromium } = require('playwright');
const fs = require('fs');

const show = new Set(['region', 'table', 'rowgroup', 'row', 'columnheader', 'rowheader', 'cell', 'generic', 'caption']);

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage({ viewport: { width: Number(process.argv[3] ?? 1280), height: 800 } });
  await page.setContent(fs.readFileSync(process.argv[2], 'utf8'));
  const cdp = await page.context().newCDPSession(page);
  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  const byId = new Map(nodes.map(n => [n.nodeId, n]));
  const walk = (n, depth) => {
    const role = n.role?.value;
    const visible = !n.ignored && show.has(role) && !(role === 'generic' && !n.name?.value);
    if (visible) {
      console.log(`${'  '.repeat(depth)}${role} "${n.name?.value ?? ''}"`);
    }
    for (const id of n.childIds ?? []) walk(byId.get(id), visible ? depth + 1 : depth);
  };
  walk(nodes[0], 0);
  await browser.close();
})();
node axtree.js site/orders.html
region "Order history, last 12 months"
  table "Order history, last 12 months"
    caption ""
    rowgroup ""
      row ""
        columnheader "Order"
        columnheader "Date"
        columnheader "Status"
        columnheader "Total"
    row ""
      rowheader "#1042"
      cell "2 Nov 2026"
      cell "Shipped"
      cell "84.90 USD"
    row ""
      rowheader "#1017"
      cell "14 Oct 2026"
      cell "Delivered"
      cell "34.90 USD"
    rowgroup ""
      row ""
        rowheader "Total spent"
        cell "119.80 USD"

caption is the table's name, and the table's name in the tree ("Order history, last 12 months") comes from it. A screen reader reads this name when entering the table; with more than one table on the page, the user tells them apart by name. Writing the title as an h2 above the table and leaving out caption is a common habit; visually the same, but the table stays unnamed. Either use caption or connect the table to that heading with aria-labelledby.

thead, tbody and tfoot group rows. In the tree, thead and tfoot appear as rowgroups; tbody does not produce a separate node in Chromium, and the rows sit directly under the table. The real benefit of these groups is structural: when the table is printed, thead can repeat on every page, targeting the header or the total row with CSS (Cascading Style Sheets) becomes easy, and JavaScript that sorts or paginates touches only the tbody.

Header cells are th, and the scope attribute says in which direction they are headers. The four cells with scope="col" became columnheaders, and the scope="row" cells holding the order numbers became rowheaders. Thanks to this, when a screen reader reaches the "84.90 USD" cell it can give the context "Total, #1042". A row header should be the value that identifies the row on its own: the order number is a good choice, a repeating status like "Shipped" is not.

The total row in tfoot also carries a row header: "Total spent", spanning three columns with colspan="3". Writing the total row as the last row of the tbody with tds is common; then the total reads like another order. For the date, the time element keeps the machine-readable value in datetime, as we saw in the series' "Text Semantics" post.

colgroup and col: Styling a Column

colgroup and col define columns in the markup. It's tempting to right-align the currency by giving the col a class and calling it done, but in CSS's table model only a few properties apply to col: border, background, width and visibility. I measured it:

const { chromium } = require('playwright');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(`
    <style>
      col.amount { text-align: end; background-color: rgb(240, 240, 240); width: 120px; }
    </style>
    <table>
      <colgroup><col><col class="amount"></colgroup>
      <tr><th scope="row">#1042</th><td>84.90 USD</td></tr>
    </table>`);
  const td = await page.evaluate(() => {
    const cell = document.querySelector('td');
    return `text-align=${getComputedStyle(cell).textAlign} width=${cell.getBoundingClientRect().width}`;
  });
  console.log(td);
  await browser.close();
})();
text-align=start width=120

width was applied (120 pixels), text-align was not (start). A cell is not a child of the col, so text-align set on a col isn't inherited either. That is why the amount class is on both the col and every td in the order table: the one on the col is only documentation, the one on the td does the alignment. col is enough for column width and background colour; for the rest you need a cell selector (e.g. td:nth-child(4)) or a class on the cell.

Complex Tables: headers and id

If every cell has one column header and one row header, scope is enough. In a report table with two levels of headers, though, each cell has to be tied to its headers one by one: an id on every header, and a headers attribute listing those ids on every cell. Bookstore's annual sales report, with half-year and quarter headers (quarters abbreviated starting with Q1 (first quarter)):

{{site/report.html}}

There is a bug in the last cell of the "E-book" row: its headers list points to a q5 that doesn't exist. Both axe and the checker found it:

const { chromium } = require('playwright');
const fs = require('fs');

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(fs.readFileSync('site/report.html', 'utf8'));
  await page.addScriptTag({ path: require.resolve('axe-core') });
  const result = await page.evaluate(async () =>
    (await axe.run(document, { runOnly: ['td-headers-attr', 'th-has-data-cells'] })).violations
      .flatMap(v => v.nodes.map(n => `${v.id}: ${n.html}`)));
  console.log(result.join('\n') || 'no violations');
  await browser.close();
})();
td-headers-attr: <table>
docker run --rm -v "${PWD}/site:/site" ghcr.io/validator/validator:latest vnu --errors-only /site
"file:/site/report.html":38.55-39.46: error: The “headers” attribute on the element “td” refers to the ID “q5”, but there is no “th” element with that ID in the same table.

The checker is more precise with line and column numbers; axe says the error is in the table. headers/id is powerful but fragile: hand-written ids easily drift in a copy-paste. Always generate such a table with a template loop, and preferably try simplifying the table first. Instead of two levels of headers, combined single-level headers such as "Q1 2026" often give a table that is easier both to read and to maintain.

Tables on Narrow Screens: A Scrollable, Focusable Wrapper

The four-column order table doesn't fit on a 390-pixel phone. The simplest solution, one that doesn't touch the semantics at all, is to put the table in a horizontally scrollable box: the div.table-scroll in orders.html with its overflow-x: auto. But a scrollable box can be scrolled with a mouse or by touch; a keyboard user first needs to be able to focus it in order to scroll it. I compared the plain wrapper with the one carrying tabindex="0", role="region" and aria-labelledby in two engines:

const pw = require('playwright');
const fs = require('fs');

const orders = fs.readFileSync('site/orders.html', 'utf8');
const variants = {
  'plain wrapper': orders.replace(' tabindex="0" role="region" aria-labelledby="orders-caption"', ''),
  'focusable region': orders,
};

(async () => {
  for (const engine of ['chromium', 'webkit']) {
    const browser = await pw[engine].launch();
    console.log(`--- ${engine} ${browser.version()}`);
    for (const [name, html] of Object.entries(variants)) {
      // on a 390-pixel screen the 640-pixel table scrolls horizontally
      const page = await browser.newPage({ viewport: { width: 390, height: 844 } });
      await page.setContent(html);
      await page.addScriptTag({ path: require.resolve('axe-core') });
      const violations = await page.evaluate(async () =>
        (await axe.run(document, { runOnly: ['scrollable-region-focusable'] })).violations.map(v => `${v.id} (${v.impact})`));
      // keyboard only: does Tab move focus to the scrollable wrapper?
      await page.keyboard.press('Tab');
      const focus = await page.evaluate(() => document.activeElement.className || document.activeElement.tagName.toLowerCase());
      console.log(`${name.padEnd(17)} focus=${focus.padEnd(13)} axe: ${violations.join(', ') || 'no violations'}`);
      await page.close();
    }
    await browser.close();
  }
})();
--- chromium 158.0.8127.18
plain wrapper     focus=table-scroll  axe: scrollable-region-focusable (serious)
focusable region  focus=table-scroll  axe: no violations
--- webkit 27.2
plain wrapper     focus=body          axe: scrollable-region-focusable (serious)
focusable region  focus=table-scroll  axe: no violations

Chromium gave focus to the plain wrapper as well: since Chrome 130, scrollable boxes with no focusable element inside are keyboard-focusable by default. WebKit did not; after Tab, focus stayed on the page, and a keyboard user can't reach the right side of the table. axe, independent of the engine, counted the plain wrapper as a serious violation. The right fix is three attributes: tabindex="0" makes the box focusable in every browser, role="region" makes it a landmark, and aria-labelledby names that landmark with the table's caption. Everything focusable needs a name; an unnamed focus stop is read only as "region" by a screen reader. A visible focus (a :focus-visible style) is a must too; that is a topic for the CSS3 series and the Accessibility (a11y) series.

Card View: CSS display and Table Semantics

Turning every row into a card on a narrow screen, instead of scrolling the table, is also common. To do this, the table's elements get display: block:

        /* on a narrow screen, turn every row into a card */
        @media (max-width: 600px) {
            table, caption, thead, tbody, tfoot, tr, th, td {
                display: block;
            }
            table {
                min-width: 0;
            }
            thead {
                position: absolute;
                width: 1px;
                height: 1px;
                overflow: hidden;
                clip-path: inset(50%);
            }
        }

I added this CSS to orders.html and ran the same axtree.js on a 390-pixel screen (node axtree.js site/cards.html 390). The tree Chromium produced is identical, line for line, to the wide-screen output above: the table, rows, headers and cells are all there. Chromium today keeps the semantics of table elements whose CSS display has changed.

But this did not hold for every browser. In accessibility expert Adrian Roselli's tests in 2020 and 2022, Safari dropped the semantics of table cells given display: block. I couldn't measure Safari's current behaviour for this post (Playwright gives no access to WebKit's accessibility tree). So if you choose the card view, test it with a real Safari and VoiceOver; if you see problems, either choose a layout that leaves the display of table elements alone (e.g. the scrollable wrapper) or restore the semantics explicitly with role="table", role="row" and role="cell". The second path needs ARIA (Accessible Rich Internet Applications) knowledge and is a topic for the Accessibility (a11y) series.

The card view has a visual cost too: because the thead is hidden, the cell values lose their headers. This is usually solved by adding a data-label to the tds and ::before { content: attr(data-label) } in CSS. Because screen readers can read this CSS-generated content too, the header may be heard twice. The card view can be a good design; but it isn't free.

Angular: A Host Element Instead of tr

Back to the guessing question. The script that builds the Angular application, opens it in a browser and counts the roles in the two tables sits next to the project folder:

import { Component, input } from '@angular/core';
import { CurrencyPipe } from '@angular/common';
import { Order } from './order';

@Component({
  selector: 'tr[appOrderRow]',
  imports: [CurrencyPipe],
  template: `
    <th scope="row">#{{ order().id }}</th>
    <td>{{ order().date }}</td>
    <td>{{ order().total | currency: 'USD' }}</td>
  `,
})
export class OrderRow {
  readonly order = input.required<Order>({ alias: 'appOrderRow' });
}
const { chromium } = require('playwright');
const http = require('http');
const fs = require('fs');
const path = require('path');

const dist = path.resolve('bookstore-web/dist/bookstore-web/browser');
const server = http.createServer((req, res) => {
  const file = path.join(dist, req.url === '/' ? 'index.html' : req.url);
  res.setHeader('Content-Type', file.endsWith('.js') ? 'text/javascript' : 'text/html; charset=utf-8');
  res.end(fs.readFileSync(file));
}).listen(18894);

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.goto('http://localhost:18894/');
  await page.waitForSelector('#books-attribute li');

  // which roles are inside each table and list in Chromium's accessibility tree?
  const cdp = await page.context().newCDPSession(page);
  const { nodes } = await cdp.send('Accessibility.getFullAXTree');
  const byId = new Map(nodes.map(n => [n.nodeId, n]));
  const roles = n => (n.childIds ?? []).flatMap(id => {
    const c = byId.get(id);
    return [...(c.ignored ? [] : [c.role.value]), ...roles(c)];
  });
  for (const id of process.argv.slice(2)) {
    const { node } = await cdp.send('DOM.describeNode', {
      objectId: (await cdp.send('Runtime.evaluate', { expression: `document.getElementById('${id}')` })).result.objectId,
    });
    const ax = nodes.find(n => n.backendDOMNodeId === node.backendNodeId);
    const counts = {};
    for (const r of roles(ax)) counts[r] = (counts[r] ?? 0) + 1;
    const summary = ['row', 'columnheader', 'rowheader', 'cell', 'listitem', 'generic']
      .filter(r => counts[r]).map(r => `${r}=${counts[r]}`).join(' ');
    console.log(`${id.padEnd(17)} ${ax.role.value.padEnd(6)} ${summary}`);
  }

  // write the browser's final DOM to disk for the checker
  fs.mkdirSync('rendered', { recursive: true });
  fs.writeFileSync('rendered/account.html', await page.content());
  await browser.close();
  server.close();
})();
node angular-dom.js orders-element orders-attribute
orders-element    table  row=1 columnheader=3 generic=6
orders-attribute  table  row=3 columnheader=3 rowheader=2 cell=4

The first table has a single row: the header row. There are no data rows; the th scope="row" cells didn't become rowheaders and the tds didn't become cells. All six cells are generic, meaningless boxes. The cause is the app-order-row element standing between the tbody and the cells in the DOM (Document Object Model): the table model expects only tr inside a tbody, and it doesn't see the cells inside an element it doesn't know as a row. It looks fine on screen because the browser still lines the cells up like a row in CSS; for a screen reader there is no data in this table.

The second table is the attribute-selector version of the same component: selector: 'tr[appOrderRow]'. Angular no longer creates its own host element; it attaches the component to the tr in the template itself. The result is three rows, two rowheaders and four cells, a correct table. Giving the input the same name as the selector (alias: 'appOrderRow') also reduces the usage to a single attribute: <tr [appOrderRow]="order">.

When I gave the checker the browser's final DOM, it stopped at the first table:

"file:/site/account.html":9.239-9.253: error: Start tag “app-order-row” seen in “table”.
"file:/site/account.html":9.239-9.253: error fatal: Cannot recover after last error. Any further errors will be ignored.

And the answer to the SSR question: because Angular builds this DOM in the browser with DOM APIs (Application Programming Interface), the parser never gets involved. If the same structure arrived from the server as an HTML string, the forgiving parser from the first post of the series would rearrange it:

const { chromium } = require('playwright');

// the structure Angular writes to the DOM, this time arriving from the server as an HTML string
const ssrHtml = `<table><tbody><app-order-row><th scope="row">#1042</th><td>2026-11-02</td></app-order-row></tbody></table>`;

(async () => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  await page.setContent(ssrHtml);
  console.log(await page.evaluate(() => document.body.innerHTML));
  await browser.close();
})();
<app-order-row></app-order-row><table><tbody><tr><th scope="row">#1042</th><td>2026-11-02</td></tr></tbody></table>

The parser moved app-order-row out of the table, in front of it, and opened a tr for the cells itself. The DOM the server sent and the DOM Angular expects are now different; when hydration meets this difference it reports an error or re-renders that part from scratch. A row component with an element selector silently breaks accessibility in client-side rendering, and loudly breaks hydration with SSR. The same problem exists for host elements inside a ul; we'll see that with lists in the next post of the series.

In Production

Don't use table for layout. This habit from early-2000s page layouts is rare today outside email templates, but a CMS (Content Management System) editor adding a table for two-column text still happens. A screen reader may take such a table for a data table and announce "2 columns, 1 row". If it is unavoidable, role="presentation" removes the table's semantics; but the real fix is CSS Grid or Flexbox.

In sortable tables, state which column is sorted and in which direction with aria-sort on the th, as in orders.html, and make whatever triggers sorting a button inside the th; a clickable th doesn't work with a keyboard. How aria-sort is announced by screen readers is covered in the Accessibility (a11y) series.

If you use pagination or virtual scroll in large tables, rows that aren't on screen aren't in the DOM either, and a screen reader says "20 rows" instead of "50 rows". aria-rowcount and aria-rowindex exist for this case; but first measure whether you really need virtual scroll. An order history of a few hundred rows renders comfortably as a plain table.

Common Pitfalls

First, writing header cells as td with a bold font. Visually a header, semantically an ordinary cell; the table ends up without headers.

Second, writing the table's name in a standalone heading above the table instead of caption, without connecting them.

Third, writing the total row as the last row of the tbody and without a row header.

Fourth, leaving the scrollable wrapper without tabindex. It seems to work in Chromium; in WebKit a keyboard user can't reach half of the table.

Fifth, giving a row or cell component an element selector in Angular. Components inside a table are written with attribute selectors such as tr[appX] or td[appX].

A Small Experiment

★☆☆ Delete the caption in orders.html, put an h2 above the table instead, and run axtree.js. What happened to the table's name? Then add aria-labelledby to the table and try again.

★★☆ Rewrite report.html without headers/id, reducing the two-row header to a single row (such as "Q1 2026"). Run axe and the checker on both versions and compare which one is easier to read and maintain.

★★★ In your own Angular project, find components used directly inside a tbody, tr, ul or ol (a grep over the templates does the job). Convert one to an attribute selector and compare the roles in the accessibility tree before and after with a script like angular-dom.js.

The table was where semantics broke most visibly. In the next post we generalise that lesson to the whole page: the "native first" principle, lang and dir, positive tabindex, the difference between hidden, aria-hidden and inert, reading the accessibility tree in DevTools, and checking Angular host elements and Razor Tag Helper output.