Patterns / Data Tables
The semantics are the component. Everything else is paint.
A data table is a native <table> first, always. Density, sorting, pagination and the mobile transform are behavior layered on top of correct structure, they are not a substitute for it. Reach for an ARIA role only when native HTML genuinely cannot express the interaction. This page is the last word on how we build tables that stay readable, operable by keyboard, and announced correctly by a screen reader, from a 320px phone to a wide analytics grid.
Native <table> first, never div-soup
Build the table from real elements: a <table> with a <caption>, a <thead> and <tbody>, and <th> cells that carry scope. This is not stylistic. Without <th>, scope and the table role, a screen reader cannot announce "column Amount, row Order 4521" when the user lands on a cell. The row and column relationship is simply lost, and there is no ARIA shortcut simpler than using the real element. MDN puts it plainly: a logical structure with semantic markup is not only easier to style, it is what makes a table understandable and navigable by assistive technology and search engines alike.
Rule Use a real <table> with <caption>, <thead> / <tbody>, and <th scope="col"> for column headers plus <th scope="row"> for each row's identifying cell.
Why WCAG 2.1 SC 1.3.1 Info and Relationships (Level A) requires that structure conveyed visually be programmatically determinable. Scope is inferred in simple tables, but MDN and GOV.UK both advise setting it explicitly, because some assistive technology draws the wrong inference.
Rule The <caption> is the table's accessible name. Give every table one.
Why GOV.UK: a caption helps users find, navigate and understand tables. It lets a screen reader user decide whether to read on or skip the table entirely.
Rule For multi-level or spanned headers, prefer splitting into smaller related tables over reaching for headers / id wiring.
Why MDN recommends breaking a complex table apart rather than relying on colspan / rowspan. It helps assistive technology and lowers cognitive load. If you are reaching for headers / id, first ask whether this should be two tables.
| Order | Customer | Status | Amount (COP) |
|---|---|---|---|
| 4521 | Mariana Restrepo | Paid | 180,000 |
| 4520 | Jordan Ellis | Pending | 92,500 |
| 4519 | Riley Chen | Failed | 240,000 |
The fork: <table> vs role="grid"
This is the single most misunderstood decision in table design, and the most common mistake is over-reaching for role="grid". The default is a static <table>. Most product tables, even with sorting, filtering, pagination, row links and row actions, are not grids. The WAI-ARIA APG is explicit: like an HTML table, a WAI-ARIA table is a static tabular structure, not an interactive widget. You only graduate to a grid when the table is a spreadsheet the user operates cell by cell with arrow keys.
Use a static <table> (default) when | Use role="grid" only when |
|---|---|
| The table presents data to read, scan and compare. | The table is an interactive widget operated like a spreadsheet. |
| Cells may hold links or buttons and the user tabs through them normally. | You need cell-by-cell arrow-key navigation with a single tab stop for the whole grid. |
| You have sorting, filtering, pagination. None of these require a grid. | You have editable cells or complex keyboard row and column selection. |
A grid is a heavyweight commitment. Once you adopt role="grid" you own focus management by roving tabindex (exactly one focusable element in the tab sequence at a time) and the full keyboard model: arrow keys move one cell and stop at edges, Home / End jump to row ends, Ctrl+Home / Ctrl+End to grid corners, and in an edit grid Enter or F2 enters edit mode while Escape returns to navigation. Do not sign up for this to get sortable columns.
Sorting that a screen reader can follow
Rule The sort control is a native <button> inside the <th>. Sort state lives in aria-sort on that <th>, on one column at a time.
Why The APG sortable-table example wraps header text in a button, so keyboard support is free from the browser. Roselli is firm: never set aria-sort on more than one header at once.
Rule Signal direction by shape, a filled versus outline arrow marked aria-hidden="true", not by color.
Why Encoding sort purely through color risks failing WCAG 1.4.1 Use of Color. The glyph is hidden from the accessible name so it does not pollute the button label.
Rule Pair sorting with an optional polite live region that announces the change.
Why Roselli tested support: NVDA, JAWS, Narrator and VoiceOver on iOS announce aria-sort, but VoiceOver on macOS and TalkBack on Android do not. A live region is the only way those users hear that the order changed.
Rule Communicate sortability itself through a <caption> hint, not a verbose per-button aria-label.
Why The caption is announced once on table entry. Roselli found per-button labels translate poorly and are read on every cell. The visible button text must carry the meaning, because not every screen reader user is blind.
| Session | ||
|---|---|---|
| Opening keynote | 1,240 | 86 |
| Design systems | 830 | 54 |
| Payments deep dive | 610 | 41 |
Sorted by Attendees, descending.
Row selection with a real indeterminate select-all
Each selectable row gets a native <input type="checkbox"> whose accessible name identifies the row, not a generic "Select row". The header select-all checkbox uses the indeterminate DOM property (set in JavaScript, it is not the same as checked) when some but not all rows are selected: checked when all are, unchecked when none. All native checkboxes are tinted with accent-color: var(--color-primary) so they read as brand without losing a single built-in behavior. Announce the derived selection count through a polite live region, because assistive technology does not report it on its own.
| Attendee | Ticket | Paid (COP) | |
|---|---|---|---|
| Mariana Restrepo | General | 180,000 | |
| Jordan Ellis | VIP | 420,000 | |
| Riley Chen | General | 180,000 |
Be explicit about scope across pages. When the table is paginated, state whether a selection covers only the current page or the whole filtered set. Ambiguous selection is how users delete more than they meant to.
Density and alignment
Anchor row height to IBM Carbon's five-tier scale, the most complete published density system. Ship at least two densities: a comfortable default around 48px, which is touch and low-vision friendlier, and a compact opt-in around 32px for power users comparing many rows at once. Never ship compact only.
| Carbon tier | Height | Use when |
|---|---|---|
| Extra small / Compact | 24px | Dense exploration, expert users, maximum rows on screen. |
| Small / Short | 32px | Dense with a little more breathing room. |
| Medium | 40px | Balanced default for many products (added in Carbon 11). |
| Large / Default | 48px | Comfortable default, touch friendlier. |
| Extra large / Tall | 64px | Rows with two lines, a value plus secondary text. |
Rule Left-align text, right-align numbers, and align each header with its own column.
Why Right-aligned digits line up by place value so columns of numbers become scannable. GOV.UK ships exactly this with numeric cell and header modifiers.
Rule Put font-variant-numeric: tabular-nums on numeric columns.
Why Tabular figures occupy equal width, so a column of numbers aligns vertically digit by digit. This is what tabular figures are for.
Rule Interactive targets inside cells (sort buttons, row-action buttons, checkboxes, pagination links) meet at least 24 by 24 CSS px, or the 24px-spacing exception.
Why WCAG 2.2 SC 2.5.8 Target Size (Minimum). A 24px compact row can hold a 24px target but leaves no spacing margin, so compact density plus icon buttons is a target-size risk. Prefer comfortable density anywhere row actions are tappable.
| City | Tickets |
|---|---|
| Denver | 12,480 |
| Portland | 8,210 |
| Cali | 4,905 |
| City | Tickets |
|---|---|
| Denver | 12,480 |
| Portland | 8,210 |
| Cali | 4,905 |
Zebra striping vs dividers
The evidence here is softer than most people assume, so do not over-claim. NN/g endorses borders, zebra striping and hover highlighting as valid aids for keeping your place across a wide row, but it does not publish a controlled study crowning a winner. Anyone who tells you striping always beats dividers is inventing a result.
Rule Pick one row-tracking aid, driven by table width, and do not stack them.
Why For wide tables with many columns, zebra striping or a row hover highlight materially helps the eye stay on one record while scanning across. For narrow tables, subtle horizontal dividers or nothing at all is cleaner, and striping only adds noise. Stacking striping plus heavy borders plus hover is visual clutter.
Rule Always add a row hover highlight and a matching focus highlight for keyboard users.
Why Hover helps mouse users track a row, but keyboard users need the same anchor on focus, never one without the other.
Data formatting
| Type | Format rule |
|---|---|
| Numbers | Right-align, tabular-nums, group thousands. Keep a shared unit in the header, Price (COP), not repeated in every cell. |
| Currency | Right-align, one consistent symbol and decimal count per column. For this product's COP context, whole pesos. |
| Dates | One format per column. Wrap the machine value in <time datetime="...">. Show relative dates only when a precise value is available on hover or focus. |
| Text | Left-align. Decide truncate or wrap per column, not per table. |
| Empty cells | Use an explicit placeholder, an em value shown as a dash, with an accessible meaning, never a blank cell that reads as nothing. |
Rule Wrap content the user must read in full (names, addresses, descriptions), pairing it with the tall 64px density.
Why Truncating a value the user needs to read makes the table useless for that task.
Rule Truncate with an ellipsis only for low-value tails (IDs, URLs, long machine strings), and always give an accessible way to recover the full value with title or a tooltip.
Why Truncation buys column alignment, but hiding a value with no way to read it fails the user who needs it.
Pagination vs infinite scroll vs load-more
Data tables are goal-oriented lookup and compare surfaces, so the default is pagination. NN/g finds infinite scroll suits homogeneous feeds with no particular task (entertainment, news, social), and explicitly recommends avoiding it when users need to find something specific, compare items in a long list, or return to an item they saw. In an infinite list it is hard to remember where an item was, footers become unreachable, and the page gives a false sense of completeness.
| Pattern | Use for a table when |
|---|---|
| Pagination (default) | Users look up, compare, and return to items, and need a sense of position, a total, and access to a footer or summary. |
| Load-more button | Mostly-browse lists where you still want footer access and user-controlled loading. A middle ground. |
| Infinite scroll | Rare for tables. Only a pure exploratory feed with no find, compare or return task. |
Rule Always show the current range and the total, "1-25 of 320", and let users choose the page size.
Why It gives position and scope, directly answering the "where am I" problem infinite scroll creates.
Rule Wrap controls in <nav aria-label="Table pagination">, mark the current page with aria-current="page", meet SC 2.5.8 target size, and announce the new range politely.
Why WCAG cites pagination links as a case for the 24px target-size rule. Swapping the table body is not auto-announced, so a live region carries the change.
Filters and search
Give the table a global search that filters across columns, and per-column filters where users repeatedly narrow by known dimensions like status or date. Keep the sort button and any column filter as separate, separately-labeled controls, never overloaded onto one target. Above all, make the current filter state visible.
Rule Every applied filter renders as a visible, removable chip with an accessible name, plus a "Clear all".
Why Chips are the honest answer to "what am I currently looking at". A filtered table that hides its own active filters is a dark pattern of confusion.
Rule State the filtered count against the total on the table itself, "Showing 12 of 320, filters active", and announce result counts through a polite live region.
Why Users must never mistake a filtered view for the whole dataset. When filtering reaches zero rows, show the no-results state, not the empty state.
Showing 12 of 320, filters active.
Row actions and bulk actions
Show one or two primary, high-frequency actions as inline buttons in an actions column. Collapse three or more, or any secondary actions, into an overflow kebab menu to control width and noise. Icon-only buttons must carry an aria-label. For bulk work, select rows through the checkboxes from section 4, then reveal a contextual toolbar when at least one row is selected.
The hover-only trap. Row actions must never appear on mouse hover alone. Hover does not exist on touch and is unreachable by keyboard. Actions must be always rendered (optionally lower contrast until hover or focus), never hidden with display:none behind a hover, and every target meets the 24 by 24px minimum.
| Attendee | Actions |
|---|---|
| Mariana Restrepo | |
| Jordan Ellis |
| Attendee | Actions |
|---|---|
| Mariana Restrepo | hidden until hover |
| Jordan Ellis | unreachable on touch |
States: empty, loading, error, no results
A table has four states beyond happy-path data, and two of them are constantly confused. The empty state (no data yet) explains why the table is empty and offers a primary action to add the first record. The no-results state (after filtering to zero rows) is different: it must say the filters matched nothing and offer a one-click Clear filters. Loading prefers skeleton rows that mirror the real column layout over a bare spinner, with the region marked aria-busy="true" while it loads. On error, keep any stale data visible under an error banner with a retry rather than blanking the screen.
| Attendee | Paid (COP) |
|---|---|
| Mariana Restrepo | 180,000 |
| Jordan Ellis | 420,000 |
Mobile, the hard problem
Tables do not fit narrow viewports, and WCAG SC 1.4.10 Reflow gives them a specific carve-out. Content must reflow to 320 CSS px without two-dimensional scrolling, except for parts that require a two-dimensional layout for meaning, and the standard names "data tables (not individual cells)" as exactly such a part. In plain terms: a data table is permitted to scroll horizontally on a phone and you do not fail Reflow for it, as long as the surrounding page (headings, search, filters) still reflows. Horizontal scroll is a legitimate, standards-blessed strategy for tables, not a hack.
Keep the real <table>. Wrap it in a focusable, labeled scroll region and freeze the identifying first column as the anchor.
Gain: bulletproof semantics, about six lines of CSS, no JS, no data duplication. Lose: the user scrolls to reach far columns, so mitigate with the sticky first column.
Collapse each row into a key and value card with td::before labels. Readable one record at a time.
Gain: no horizontal scroll, great for inspecting single records. Lose: display:block destroys table semantics, so you MUST restore roles with ARIA, and cross-record comparison becomes nearly impossible.
Show only the top-priority columns on narrow screens, reveal the rest behind a columns toggle or an expandable row.
Gain: keeps a real table and fits the width. Lose: hidden data is out of sight, so the toggle state must be announced and persistent.
Show a scannable list of summary items linking to a full detail view. Put the real table on wider viewports.
Gain: simplest reading on a phone. Lose: cross-column comparison is not available on mobile, so use it only when identity dominates.
The conflict, resolved in favor of Roselli. CSS-Tricks (Coyier, 2011) claims the display:block card transform stays accessible. Roselli's assistive-technology testing (2017, updated 2024) shows the opposite: as soon as the table becomes display:block, screen readers stop treating it as a table. We trust Roselli, the AT-tested specialist. If you use the card pattern you must repair semantics with ARIA roles. Otherwise, prefer Pattern A.
Mobile decision ladder
display:block keeps semantics.What that looks like on a 320px screen. The same attendee dataset, two ways. A record table becomes stacked cards, one identity per card, one thing per line. A comparison table stays a table and scrolls sideways with the identifier column pinned. Which way you go is the ladder above, not a coin toss.
Tab to the table below, then scroll it sideways. The Attendee column stays pinned.
| Attendee | Ticket | City | Status | Paid (COP) | Registered |
|---|---|---|---|---|---|
| Mariana Restrepo | General | Denver | Paid | 180,000 | Oct 3, 2025 |
| Jordan Ellis | VIP | Portland | Paid | 420,000 | Oct 3, 2025 |
| Riley Chen | General | Cali | Pending | 180,000 | Oct 4, 2025 |
Why the region needs tabindex="0". Roselli's under-engineered pattern wraps the table in <div role="region" aria-labelledby="..." tabindex="0">. The tabindex satisfies WCAG SC 2.1.1 Keyboard so a keyboard user can tab to the region and scroll it. Avoid CSS scroll-snap here: it clips cells, and users need to compare adjacent cells freely.
Advanced: editing, expansion, pinning, virtualization
| Feature | Rule and cost |
|---|---|
| Editable cells / data grid | This is where you graduate to role="grid" with the edit-grid keyboard model (Enter or F2 to edit, Escape to exit). Do not bolt inline edit onto a static table without the grid contract. |
| Column resize / reorder | Provide keyboard-operable handles, announce the new size or position, and persist per user. High effort, ship only if users demonstrably need it. |
| Expandable rows | A disclosure <button> per row with aria-expanded toggling an adjacent detail region. A natural home for secondary columns on mobile Pattern C. Test density against expanded content together, Carbon logged a bug where row density skipped the expansion. |
| Column pinning | Use CSS position: sticky, and pin only the identifier column. Freezing many columns eats mobile width. |
| Virtualization | Render only visible rows for very large datasets. It breaks find-in-page and can confuse assistive technology because off-screen rows are not in the DOM. If you virtualize, set aria-rowcount and aria-rowindex so AT knows the true totals. Treat it as a last resort after pagination. |
Sources
- MDN Web Docs, the
<table>element and its accessibility. Semantic markup makes tables understandable to assistive technology and search engines, the correct child order (caption,colgroup,thead,tbody,tfoot), explicitscope, the split-over-headers/idguidance, and wrapping dates in<time>. developer.mozilla.org/en-US/docs/Web/HTML/Element/table - WAI-ARIA Authoring Practices Guide, Table and Grid patterns. Native table is strongly encouraged over
role="table", a WAI-ARIA table is static not interactive, and a grid is a composite widget with a single tab stop and roving tabindex. The Sortable Table example wraps header text in a button and drives state witharia-sort. w3.org/WAI/ARIA/apg/patterns/table SPA index - Adrian Roselli. Sortable Table Columns (2021, updated 2024): one
aria-sortat a time, VoiceOver macOS and TalkBack do not announce it so add an optional live region, signal direction by shape not color, prefer a<caption>hint. Under-Engineered Responsive Tables (2020): therole="region"plustabindex="0"scroll pattern, no scroll-snap. A Responsive Accessible Table (2017, updated 2024):display:blockstops screen readers treating it as a table. Browser note: Safari only restored table semantics under adisplayoverride in Safari 17 (2023), anddisplay: contentsstill drops semantics across browsers, so do not lean ondisplaytricks. adrianroselli.com - GOV.UK Design System, Table component. Captions help users find and understand tables,
scopedistinguishes row from column headers, numeric cell and header modifiers right-align, and aim for less tabular data on small screens. design-system.service.gov.uk/components/table - Nielsen Norman Group. Data Tables, Four Major User Tasks (Laubheimer, 2022): borders, zebra striping and hover highlighting all help place-keeping, and freeze header rows and columns. Infinite Scrolling, When to Use It (Neusesser, 2022): avoid infinite scroll for find, compare and return tasks, so tables default to pagination. nngroup.com/articles/data-tables
- Nielsen Norman Group, Amy Schade, Mobile Tables (2017). The primary source for the mobile guidance this page gives: keep the header and the identifying first column in view (sticky) while the rest scrolls, and use an accordion or expand to reveal secondary columns on narrow screens. nngroup.com/articles/mobile-tables
- Baymard Institute (2024). Use product tables for desktop product listings: their testing shows a full comparison table degrades poorly on a phone, so on mobile it becomes a curated list view of two to three key attributes and the full comparison is deferred to desktop. This is the evidence behind the "not a table" rung of the decision ladder. baymard.com
- W3C, Understanding WCAG. SC 1.3.1 Info and Relationships (A) as the basis for table semantics, SC 1.4.1 Use of Color (A), SC 2.1.1 Keyboard (A), SC 1.4.10 Reflow (AA) with its data-table carve-out, and SC 2.5.8 Target Size Minimum 24px (AA) citing pagination links. w3.org/WAI/WCAG22/Understanding
- IBM Carbon Design System. Row-height tiers Compact 24, Short 32, Medium 40, Default 48, Tall 64 from GitHub issue #8874, and the row-size versus expanded-content bug from issue #4796. github.com/carbon-design-system/carbon values from repo issues, rendered docs are a SPA
- CSS-Tricks, Responsive Data Tables (Coyier, 2011). The
display:blockcard transform withtd::beforelabels. Cited for the pattern, and for the accessibility claim that Roselli's testing contradicts. Resolved in favor of Roselli. css-tricks.com/responsive-data-tables - House rules layered on the cited guidance, not verbatim claims: the width-driven zebra-versus-dividers heuristic (NN/g endorses the aids but publishes no winner),
tabular-numson numeric columns (standard CSS, GOV.UK documents only right-alignment), the checkbox plus indeterminate select-all with bulk announce, the sticky-header guidance, and the filter, states and virtualization accessibility hooks (standard ARIA and WCAG).