Bridge view

Table

A two-dimensional data grid with column headers and rows of cells, used for presenting tabular data where users scan and compare attributes across many records — inventory, contacts, transactions, search results with multiple sortable fields. Distinct from ListItem (1D rows; one-attribute-per-row) by the second axis of columns, and distinct from the APG `grid` pattern (interactive cells with 2D arrow-key navigation) by treating cells as static data unless explicitly escalated. Native HTML semantics — `<table>`, `<caption>`, `<thead>`, `<tbody>`, `<th scope="col">`, `<td>` — are the canonical baseline; ARIA roles only when the structural elements are unavailable.

Also called Data table Data grid

When to use

Use

For tabular data where users need to scan and compare attributes across many records — inventory tables, transaction lists, contact directories, search results with sortable columns, spreadsheet-style data displays. Use when the second axis (columns) carries meaning beyond a single-attribute-per-row list. Pair with pagination or virtualization for large datasets; pair with toolbar for filtering and bulk actions; pair with breadcrumbs or sidebar-nav for navigation context.

Avoid

For one-dimensional collections of rows where each row is a discrete unit — that is `ListItem`. For independent record-objects without cross-record comparison — that is `Card`. For interactive cells with editing, focus management, and 2D arrow-key navigation — escalate to the APG `grid` pattern (separate canonical entry, future). For hierarchical tabular data with parent-child rows — escalate to the APG `treegrid` pattern. For narrow viewports where the column count exceeds available width — collapse to a stacked-card layout per the responsive rules below; do not force horizontal scroll without a scrollable-region wrapper.

Versus related

  • list-item

    `Table` adds a second axis (columns plus column headers) and column-level affordances (sort, select-all, resize); `ListItem` is one-attribute-per-row inside a 1D list. Choose Table when comparison across attribute columns matters (price vs date vs status); choose ListItem when each row is a discrete unit (notification, contact, settings entry) without cross-row attribute comparison.

  • card

    A wall of `Card` reads as a feed of independent records each standing on its own (a product card, a profile card); `Table` presents the same records as scannable rows so users compare attributes across many records. Use Table for inventory / contacts / transactions where the comparison-across-rows is the primary task; use Card when each record is consumed as a unit and the cross-row comparison is incidental.

  • grid-pattern

    `Grid` is the APG escalation for interactive cells — 2D arrow-key navigation, focus management on cells, editable cells, single-tab-stop into the grid (roving tabindex). `Table` keeps cells as static data and relies on standard tab order through interactive descendants (links, buttons, checkboxes). Escalate to Grid when cell content is itself interactive or editable; stay on Table for read-only data display.

  • tree-grid

    `TreeGrid` adds hierarchical row semantics — `aria-level`, `aria-expanded`, parent-child rows that collapse / expand. `Table` is flat. Use TreeGrid for org charts, file managers with column metadata, nested budgets; use Table when rows are siblings without a parent-child relationship.

  • pagination

    `Pagination` partners with `Table` to chunk large datasets — pagination is the navigation control that sits adjacent to the table; `Table` is the data surface. Use both together for paginated tables; use Table alone when all rows fit in a single viewport (or when virtualization replaces pagination as the size-handling strategy). The decision is structural not stylistic: do users navigate between bounded windows of the dataset (Pagination) or scroll a continuous virtualized list (Table-only)?

Table presents records as rows and attributes as columns so users can compare values across many items. The reference documents the caption-as-accessible-name contract, the `<th scope>` rule that programmatically associates each cell with its header, the sort-button-not-clickable-icon discipline, the `aria-checked="mixed"` bulk-selection state, and the focusable scrollable-region wrapper required when the table overflows horizontally. Density is a property (comfortable / compact); the structural variant axis captures whether rows are selectable (leading checkbox column) or not. Performance budgets cover virtualization threshold, sticky header reflow cost, and sort-interaction latency.

Highlight
Fig 1.1 · Table · Bridge view
Both

Figma↔Code mismatches

Where designer and developer worlds typically misalign on this component.

  1. 01
    Figma

    Table title styled as a heading floating above the table frame

    Code

    `<caption>` element rendered inside `<table>` (programmatic accessible name)

    Consequence

    Designers compose tables with a sibling `<h2>` or text label above the table; developers move the label inside `<table>` as `<caption>`. The two surfaces look identical but the design file does not communicate the programmatic-association contract — implementations may ship a heading-above-table without `<caption>`, breaking the AT name-of-table announcement and WCAG 1.3.1 Info-and-Relationships.

    Correct

    Document the caption as a structural slot inside `<table>` in the anatomy. Figma carries the caption as a child of the table component (not a sibling heading). Code authors `<caption>` first child of `<table>`; for visually-hidden captions, use sr-only CSS rather than omitting the element.

  2. 02
    Figma

    Sort indicator drawn as a decorative arrow next to the column label

    Code

    `<button>` inside `<th>` with `aria-sort` on the parent header

    Consequence

    Designers draw the sort affordance as a static arrow icon next to the column name; developers ship a `<button>` wrapper so the affordance is keyboard accessible. Without explicit anatomy, the button states (default / hover / focus-visible / active / pressed) are missing from the design surface — implementations ship the visual but lose focus rings, hover affordances, and the keyboard activation contract.

    Correct

    Document the sort-button as an interactive slot separate from the th-col structural slot. Figma carries a Button instance variant for sortable columns with explicit hover / focus-visible / pressed states. Code ships `<button>` inside `<th>`; the parent `<th>` carries `aria-sort`; the button's icon is decorative (`aria-hidden`).

  3. 03
    Figma

    Header bulk-checkbox drawn with binary checked / unchecked states

    Code

    Native `<input type="checkbox">` with `indeterminate` DOM property plus `aria-checked="mixed"`

    Consequence

    Designers author the header checkbox as a two-state component (checked / unchecked); developers add the indeterminate state for partial selection. The third state is invisible in the design file — designers reviewing implementations may flag the indeterminate glyph as a bug. Without canonical anatomy, the third state slips out of the design system and AT users hear "checked" or "unchecked" when the truth is "some selected".

    Correct

    Document the row-checkbox-header as a three-state slot (unchecked / mixed / checked) in the anatomy. Figma Checkbox component carries an explicit indeterminate variant (typically a horizontal-bar glyph). Code sets `element.indeterminate = true` AND `aria-checked="mixed"` when the selection is partial.

  4. 04
    Figma

    Horizontal scroll designed as silent overflow (no visible boundary)

    Code

    `<div role="region" tabindex="0" aria-labelledby>` wrapping `<table>`

    Consequence

    Designers compose tables that visually overflow on narrow viewports without indicating where the scroll boundary is; developers wrap the table in a focusable region so keyboard users can scroll via arrow keys. Without explicit anatomy, the wrapper is invisible in the design file — implementations may ship `overflow-x: auto` on the table itself, losing the keyboard-discoverable region role and the focus ring that signals the scroll boundary.

    Correct

    Document the scrollable-region as a wrapper slot in the anatomy. Figma carries the scroll-container as a parent frame of the table with a visible focus-ring treatment on focus-visible. Code ships `<div role="region" aria-labelledby="caption-id" tabindex="0">` around `<table>` whenever horizontal overflow is possible.

  5. 05
    Figma

    Empty state drawn as a separate frame replacing the table on no-data

    Code

    `<tr><td colspan="N">` empty-state row inside `<tbody>`

    Consequence

    Designers swap the entire table-frame for a separate empty-state composition; developers keep the table structure and render an empty-state row inside `<tbody>`. The two surfaces shape the empty transition differently — designer's swap loses the caption / header context, developer's row preserves it. Without canonical anatomy, the design surface may communicate "no table here" while the code announces "table with one informational row".

    Correct

    Document the empty-state as an in-table slot inside `<tbody>`. Figma carries an "Empty" variant of the table that retains caption and header rows but replaces body content with the empty-state cell. Code ships `<tr><td colspan="${columnCount}" role="status">` so the empty-message announces on data-empty transition without re-focusing.

Both

Variants, properties, states

Variants

Structurally different versions of the component.

default selectable

Properties

The same component, parameterised.

PropertyType
density comfortable | compact
stickyHeader boolean
striped boolean
bordered boolean
hoverable boolean
sortable boolean
multiSort boolean
resizable boolean
virtualization boolean

States

Browser/user-driven (interactive) vs. app-driven (data).

KindStates
interactive
hoverfocus-visibleactive
data
defaultloadingemptyerrorhasSelectionhasSorting
Both

Figma ↔ Code property map

FigmaKindCodeNotes
VariantEnumvariantMaps default / selectable. Selectable adds the leading checkbox column with mixed-state header.
DensityEnumdensitycomfortable / compact. Drives row-padding and font-size.
Sticky HeaderBooleanstickyHeaderWhen true, `<thead>` uses `position: sticky` so header stays visible during vertical scroll.
StripedBooleanstripedAlternating row backgrounds for visual scannability of dense tables.
BorderedBooleanborderedVisible cell borders. Default is borderless with whitespace-only separation per Polaris / Material 3 convention.
HoverableBooleanhoverableRow highlight on hover. Default true; disable for non-interactive presentational tables.
SortableBooleansortablePer-column sort-button affordance plus `aria-sort` on header cells. Multi-sort via the multiSort property.
Multi SortBooleanmultiSortWhen true, multiple columns can carry non-`none` `aria-sort` simultaneously; sort precedence is implementation-defined.
ResizableBooleanresizablePer-column resize-handle inside `<th>`; columnResize event fires on drag-end.
VirtualizationBooleanvirtualizationRender only viewport-visible rows; requires `aria-rowcount` on `<table>` and `aria-rowindex` per rendered `<tr>`.
CaptionSlotcaptionAccessible name for the table; visually hidden via sr-only when context already names it.
Empty StateSlotemptyStateRenders inside `<tbody>` as a colspan-full row when the dataset is empty.
ToolbarSlottoolbarOptional region above the table for filter / search / bulk-action controls; rendered as a sibling of `<table>` outside the scrollable-region.
Both

State transitions

FromToTrigger
defaultloadingUser activates a filter / sort / pagination control that requires async data fetch
loadingdefaultAsync fetch resolves with rows
loadingemptyAsync fetch resolves with zero rows
loadingerrorAsync fetch rejects
defaulthasSelectionUser toggles a row checkbox or activates select-all
defaulthasSortingUser activates a sort-button on a column header
Designer

Figma anatomy

Slot Figma type Hint
scrollable-region frame Scroll-container frame; visible focus ring on tabindex; never collapsed by overflow:visible
container frame Table frame with auto-layout vertical for header / body / footer rowgroups
caption text Caption text style; positioned at the top of the table; visibility per "Visible Caption" property
thead frame Header rowgroup; sticky-position variant per "Sticky Header" property
tbody frame Body rowgroup with repeating row instances
tr instance Row instance; selected / hover / sorted-column states via component variants
th-col instance Column header cell; sort-affordance / resize-handle variants
td instance Data cell instance; numeric / text / status-badge / actions variants
sort-button instance Button instance with column label plus sort-indicator icon; ascending / descending / none variants
row-checkbox-cell instance Checkbox cell instance; checked / unchecked variants
row-checkbox-header instance Header checkbox instance; checked / unchecked / indeterminate variants
empty-state instance Empty-state instance with heading plus body plus optional action button
Dev

Code anatomy

Slot Code slot Semantic
scrollable-region scrollable-region region-with-tabindex
container container table
caption caption caption
thead thead thead
tbody tbody tbody
tr tr tr
th-col th-col th-scope-col
td td td
sort-button sort-button button
row-checkbox-cell row-checkbox-cell td-with-checkbox
row-checkbox-header row-checkbox-header th-with-checkbox-mixed
empty-state empty-state td-colspan-full
Dev

Cross-framework expression

FrameworkStructure mechanismVariant mechanism
Web Components A `<ui-table>` host with `variant`, `density`, `sortable`, `selectable`, `sticky-header` attributes; renders slotted `<ui-th>` and `<ui-td>` children. The host wires `aria-rowcount` for virtualized data and propagates `aria-sort` from the activated sort-button up to its parent `<th>` via attribute reflection. The wire-name `ui-table` becomes `md-table`, `mat-table`, `pf-table` under prefix substitution per design system; the canonical wire-name stays `table`. Attributes drive variants and properties (`variant="selectable"`, `density="comfortable"`, `sortable`, `sticky-header`); CSS uses logical properties for column-flow direction so RTL flips the logical-end column. Form participation is host-level only (table is not formAssociated); selection state is exposed via a `selection-change` CustomEvent.
React Compound component (TanStack Table for headless logic, React Aria Table for accessibility-first composition, Material Table for opinionated styling). Declarative columns ([{ id, header, accessor, sortable }]) plus rows (data: T[]) render via render-props or compound children (`<Table.Header>`, `<Table.Row>`, `<Table.Cell>`). Selection state hoists to a controlled / uncontrolled boundary via `selectionState` / `onSelectionChange`; sort state via `sortState` / `onSortChange`. Props with class-variance-authority for variant + density classes; boolean props for sortable / selectable / striped / bordered. Virtualization plugs in via composition (`@tanstack/react-virtual` wrapping the rendered rows). React Hook Form / Formik observe form state at the consumer level — table itself is not form-bound.
Angular (signals) Angular component with `input<TableVariant>('variant')`, `input<Density>('density')`, `input<Column[]>('columns')`, `input<T[]>('rows')`. `MatTable` (Material) and Angular CDK Table both surface column-definition directives (`MatColumnDef` + `matCellDef` + `matHeaderCellDef`); the canonical reference treats columns as data, not directives, and lets implementations choose. Selection state via two-way `model<Set<RowId>>('selection')`; sort state via `model<SortState>('sort')`. `[attr.role]="'table'"` (implicit via native element); host-binding `[class.density-compact]` etc. Signal-driven reactive sort and selection — `effect()` re-renders rows when `sort()` changes. `aria-rowcount` bound via `[attr.aria-rowcount]="totalRowCount()"`.
Vue Single-file component with default slot for column definitions and rows. PrimeVue DataTable, Naive UI DataTable, Element Plus Table as third-party precedents. `defineProps` exposes `columns`, `rows`, `selection` (v-model), `sort` (v-model). Headers and cells render via named slots so consumers customise per-column rendering without prop-drilling. `defineProps` with literal-union types (`density: 'comfortable' | 'compact'`); v-model:selection and v-model:sort for two-way state binding. Virtualization via composition (vue-virtual-scroller) wrapping the rendered rows. CSS variables drive density spacing so consumers can override per-instance.
Both

Events

  1. sortChange
    Payload
    `{ columnId: string, direction: 'asc' | 'desc' | 'none' }` (single-sort) or `Array<{ columnId, direction }>` (multi-sort). Fires when the user activates a sort-button on a column header. Consumer updates the data model and re-renders; the table reflects the new `aria-sort` on the activated column header.
    Web Components
    `sortChange` CustomEvent with `event.detail = { columnId, direction }`.
    React
    `onSortChange(state)` callback (TanStack Table idiom) or `onSortingChange(set)` (React Aria).
    Angular Signals
    `output<SortState>('sortChange')` plus two-way `model<SortState>('sort')`.
    Vue
    `@update:sort` (v-model:sort) with payload `SortState`.
  2. selectionChange
    Payload
    `{ selectedRowIds: Set<string>, isAllSelected: boolean }`. Fires when the user toggles a row checkbox, activates select-all, or selects via keyboard. Distinguishes "all-rows-selected" from "all-visible-rows-selected" — the former includes virtualized off-screen rows.
    Web Components
    `selectionChange` CustomEvent with `event.detail = { selectedRowIds, isAllSelected }`.
    React
    `onSelectionChange(selection)` callback (React Aria) or `onRowSelectionChange(state)` (TanStack Table).
    Angular Signals
    `output<SelectionState>('selectionChange')` plus two-way `model<Set<RowId>>('selection')`.
    Vue
    `@update:selection` (v-model:selection) with payload `Set<RowId>`.
  3. rowClickoptional
    Payload
    `{ rowId: string, rowData: T, originalEvent: MouseEvent | KeyboardEvent }`. Fires when the user activates a row outside any interactive descendant (link, button, checkbox). Only canonical when the consumer treats rows as navigable — typical for master-detail patterns where row click opens a detail view. Tables without row-level navigation do not observe this event.
    Web Components
    `rowClick` CustomEvent with `event.detail = { rowId, rowData }`.
    React
    `onRowClick(rowId, rowData, event)` callback; consumer guards against clicks on interactive descendants via `event.target` checks.
    Angular Signals
    `output<{ rowId: string; rowData: T }>('rowClick')`.
    Vue
    `@row-click` event with payload `{ rowId, rowData }`.
  4. expandChangeoptional
    Payload
    `{ rowId: string, isExpanded: boolean }`. Fires when the user toggles a per-row expand chevron (for tables with expandable rows showing nested detail). Only canonical when the table variant authors expandable rows; flat tables do not observe this event.
    Web Components
    `expandChange` CustomEvent with `event.detail = { rowId, isExpanded }`.
    React
    `onExpandedChange(state)` callback (TanStack Table) or per-row `onExpand(rowId, expanded)`.
    Angular Signals
    `output<{ rowId: string; isExpanded: boolean }>('expandChange')`.
    Vue
    `@update:expanded` event with payload `{ rowId, isExpanded }`.
  5. columnResizeoptional
    Payload
    `{ columnId: string, width: number }`. Fires when the user finishes a column-resize drag. Debounce mid-drag to avoid excessive layout work; emit on drag-end for the canonical commit. Only canonical when the resizable property is enabled.
    Web Components
    `columnResize` CustomEvent with `event.detail = { columnId, width }`.
    React
    `onColumnResize(columnId, width)` callback; TanStack Table provides a sizing-state model the consumer reads on commit.
    Angular Signals
    `output<{ columnId: string; width: number }>('columnResize')`.
    Vue
    `@column-resize` event with payload `{ columnId, width }`.
  6. paginationChangeoptional
    Payload
    `{ pageIndex: number, pageSize: number }`. Fires when the user activates next / previous / page- number controls. Page-index is 0-based by canonical convention (zero-based DOM, one-based display). Only canonical when pagination is authored as part of the table — server-paginated tables observe this to fetch the next page.
    Web Components
    `paginationChange` CustomEvent with `event.detail = { pageIndex, pageSize }`.
    React
    `onPaginationChange(state)` callback (TanStack Table) or `onPageChange(pageIndex)` (Polaris).
    Angular Signals
    `output<{ pageIndex: number; pageSize: number }>('paginationChange')`.
    Vue
    `@update:page` event with payload `{ pageIndex, pageSize }`.
Both

Form integration

name attribute
Table is a container for tabular data, not a form control. The `<table>` element has no `name` attribute and contributes nothing to FormData directly. Selection state, sort state, and pagination state are application-level concerns — they live in component state or URL query parameters, not in the form payload.
FormData serialization
The table contributes nothing to FormData. Inline-edit cells (cell becomes `<input>` on focus) are an escalation that requires the APG `grid` pattern plus an enclosing `<form>`; that mode is out of scope for the canonical Table pattern and documented as a future grid-pattern reference.
Both

Performance thresholds

  • virtualizationThresholdrow-count100rows

    Above ~100 simultaneously-rendered rows, the cumulative DOM-layout cost exceeds the threshold where keeping all rows in the tree is cheaper than virtualization bookkeeping. Mature libraries (TanStack Virtual, Material CDK Virtual Scroll, react-window) handle the row-recycling and `aria-rowcount` / `aria-rowindex` propagation. Below 100 rows, virtualization adds complexity without perceptible benefit.

  • stickyHeaderReflowCostframe-budget16ms

    `position: sticky` on `<thead>` forces the browser to recompute the header's painted position on every scroll event. On a 60Hz display the per-frame budget is 16.67ms; sticky-header reflow plus body-row paint must fit together. Wide tables (many columns) and tall headers (multi-row, long text) push toward the threshold. Use `contain: layout` on the thead and avoid dynamic-height header cells to stay within budget.

  • sortInteractionLatencytime-to-feedback200ms

    From sort-button activation to visible feedback (sort indicator updated, rows re-ordered or loading-state shown), the user perceives the table as responsive under 200ms. Client-side sort on cached data lands well under this; server-side sort exceeds it and MUST show an immediate loading-state on activation (within one frame, ~16ms) so the user knows the activation registered. Without the immediate loading-state, server-sorted tables feel broken on slow connections.

Both

Internationalisation

RTL · mirroring

Column flow follows logical direction — leading column (typically row-identity) sits at the inline-start, which flips between LTR and RTL. Sticky-first-column logic uses `inset-inline-start: 0` rather than `left: 0`. Numeric columns text-align the logical-end (numbers align right in LTR, left in RTL); text columns text-align the logical-start. Sort-indicator icons (chevron-up / chevron-down) are direction-neutral — vertical orientation does not flip under RTL. The kebab-menu trigger (⋮) is direction-neutral. Column headers and cell contents inherit document direction; mixed-script cells (English IDs in Arabic interfaces) handle bidi automatically when text-content carries `dir="auto"` or the bidi algorithm sees neutral digits.

Text expansion

Column headers expand 30-50% under translation (English "Status" → German "Bearbeitungsstatus" 130% longer; French "État de traitement" 200% longer). Long headers force wider columns and may push the table past viewport width — the scrollable-region wrapper absorbs this without breaking the layout. Cell content expands similarly; density compact risks crowding under expanded translations, so the canonical guidance is to default to comfortable for international audiences and let consumers escalate to compact only when authored copy is known short. Numeric cells are stable across languages (number formatting via `Intl.NumberFormat` handles locale-specific separators).

Both

Accessibility

Slot Accessibility hint
scrollable-region `<div role="region" aria-labelledby="table-caption-id" tabindex="0">`. The region role plus accessible name lets AT users locate the scrollable area via landmark navigation; `tabindex="0"` makes it focusable so keyboard users can scroll it via arrow keys. Never set `overflow: visible` on this slot — the scroll boundary IS the affordance. Visible focus ring on focus-visible.
container Native `<table>` is canonical. ARIA `role="table"` is only required when the structural element is unavailable (CSS `display: grid` layouts that lose table semantics, rare framework constraints). The element implies `role="table"` automatically; AT picks up the structure from the rowgroups and cells inside.
caption `<caption>` is the canonical accessible name for the table. SR announces it on entering the table region. For visually-hidden captions, use sr-only CSS — never omit `<caption>` and rely on a sibling heading; the heading-to-table association is implicit and not machine-readable. `aria-labelledby` pointing to a sibling heading is an acceptable fallback when `<caption>` cannot be authored.
thead `<thead>` implies `role="rowgroup"`. SR distinguishes header rowgroup from body rowgroup automatically — no ARIA needed. Sticky header (CSS `position: sticky`) is a presentation choice and does not affect AT semantics.
tbody `<tbody>` implies `role="rowgroup"`. SR uses the rowgroup boundary to announce body-row position ("row 3 of 47") distinctly from header-row position.
tr `<tr>` implies `role="row"`. For virtualized tables, author `aria-rowindex` per row matching the true index (1-based), and `aria-rowcount` on the `<table>` matching the total record count. Without the explicit indices, SR users perceive only the rendered subset and lose the dataset's actual size.
th-col `<th scope="col">` is canonical. Without `scope="col"` the header-to-cell association is ambiguous on complex tables (multi-row headers, spanning columns). Sortable columns add `aria-sort="ascending"`, `"descending"`, or `"none"` (omit when not sortable). Only one column at a time carries a non-`none` `aria-sort` for single-sort tables; multi-sort tables may carry multiple but sort-order semantics are then implementation-defined.
td `<td>` implies `role="cell"`. The cell's accessible name is its text content; for cells containing only an interactive element (icon-button, badge), the button's accessible name carries the meaning. SR announces the column header before the cell value on cell-by-cell navigation, so the cell value should be self-contained ("$1,200" not "1,200" if the column header is "Amount" without a unit).
sort-button `<button>` is canonical — never a `<span>` with onClick. The button's accessible name is the column name; the sort direction lives on the parent `<th>`'s `aria-sort`. Activating fires the sortChange event; the consumer updates the data model and re-renders. Sort-indicator icon is decorative (`aria-hidden="true"`) — the `aria-sort` attribute carries the semantic state.
row-checkbox-cell Native `<input type="checkbox">` inside the cell carries the correct semantics — checked / unchecked / disabled — without ARIA. Pair with a `<label>` (visible or sr-only) per row so AT announces the row identifier ("Row 3, Order #1042, not selected"). Avoid replacing the native checkbox with a div-with-onClick; keyboard activation and AT announcement break.
row-checkbox-header `<input type="checkbox">` inside `<th>`. When the selection state is mixed (some rows selected, not all), set `element.indeterminate = true` AND `aria-checked="mixed"`. SR announces "mixed" explicitly; sighted users see the indeterminate glyph. Without both, AT users hear "checked" or "unchecked" — both wrong. Activating toggles select-all (mixed → all-selected → none-selected or mixed → none-selected per implementation contract; canonical is mixed → all → none → all).
empty-state Render inside `<tbody>` as a single `<tr><td colspan="N">...</td></tr>` so SR users navigating the table find the message. Adding `role="status"` or wrapping in an `aria-live` region announces the transition from loading to empty without forcing a re-focus. Heading inside the cell uses `<h3>` or appropriate level matching the surrounding outline.
Both

Accessibility acceptance

Keyboard walk

KeysExpected
TabFocus moves into the scrollable-region (if the table overflows). Subsequent Tab moves through each interactive descendant in DOM order — sort-buttons in column headers, then row checkboxes, then any in-cell links / buttons, then pagination controls. The table cells themselves are NOT in the tab order; only their interactive contents are.
Shift+TabReverse focus through the same interactive descendants. Standard tab-order — no table-specific behaviour.
Arrow keys (focus on scrollable-region)Scroll the region horizontally / vertically. The region's `tabindex="0"` plus implicit keyboard-scroll behaviour drives this. Distinct from the APG `grid` pattern where arrows navigate cells; the canonical Table pattern does not move focus between cells.
Space (focus on row-checkbox-cell)Toggles the row's selection state. `selected` flips on the checkbox; `selectedRowIds` updates; selectionChange fires. The header checkbox's indeterminate state recomputes based on the new selection.
Space (focus on row-checkbox-header)Toggles the bulk-selection state. Cycles mixed → all-selected → none-selected per canonical contract. selectionChange fires with the new full selection set.
Enter / Space (focus on sort-button)Cycles the column's sort direction (none → ascending → descending → none, or ascending ↔ descending for two-state sorts). The parent `<th>`'s `aria-sort` updates; sortChange fires.

Screen-reader announcements

TriggerExpected
SR enters the scrollable-region (when present)SR announces "region, ${captionText}" so users identify the scrollable area. Followed by the table's own announcement on focus into a cell.
SR enters the tableSR announces "table, ${captionText}, ${rowCount} rows, ${columnCount} columns" using the `<caption>` text plus the `aria-rowcount` and column-derived counts. For virtualized tables, `aria-rowcount` provides the true total.
SR navigates to a data cellSR announces "${columnHeader}, ${cellValue}, row ${rowIndex} of ${rowCount}". Column header comes from the `<th scope="col">` association; row index from `aria-rowindex` or the implicit DOM position.
User activates a sort-buttonSR announces the sort direction change ("ascending" / "descending"). The activated button's accessible name plus the `aria-sort` update on the parent `<th>` produce the announcement on focus return.
User toggles row-checkbox-headerSR announces the new state ("checked", "not checked", "mixed"). When transitioning from mixed to all-selected, SR may announce the count of newly-selected rows ("47 rows selected") via an `aria-live` region or the implicit checkbox-state announcement.

axe-core rules to assert

  • aria-required-attr
  • aria-required-children
  • aria-required-parent
  • aria-valid-attr-value
  • color-contrast
  • scope-attr-valid
  • td-headers-attr
  • th-has-data-cells
  • empty-table-header
  • table-fake-caption

Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe: /api/components/table/a11y-fixture.json

Both

Contracts

Non-negotiable contracts

  1. WCAGWCAG 1.3.1 Info and Relationships

    The table has an accessible name — `<caption>` as the first child of `<table>` is canonical, with `aria-labelledby` pointing to a sibling heading id as an acceptable fallback. Implementations that ship a bare `<table>` without either lose the table's semantic identity entirely.

    Without an accessible name, SR users entering the table region hear "table, N rows, M columns" with no semantic label. Multi-table pages become unnavigable; the user cannot tell which table they have entered. WCAG 1.3.1 violation; axe-core flags `empty-table-header` and related rules.

  2. HTML specHTML standard — table model and scope attribute

    Column headers use `<th scope="col">` and (when present) row headers use `<th scope="row">`. Implementations that ship `<td>` styled as headers or `<th>` without scope break the programmatic header-to-cell association.

    Without explicit scope, complex tables (multi-row headers, spanning cells) lose the implicit association algorithm — AT users hear cell values without column context. WCAG 1.3.1 violation; axe- core flags `td-headers-attr` and `th-has-data-cells`.

  3. WCAGWCAG 4.1.2 Name, Role, Value

    Sort affordances are `<button>` elements (inside `<th>`), not click-handlers on `<span>` / `<svg>` / `<div>`. Sort direction is announced via the parent `<th>`'s `aria-sort` attribute, not via visible-text only.

    Click-handlers on non-button elements are invisible to keyboard and AT users — Tab does not focus them, Space and Enter do nothing, AT announces no role. WCAG 4.1.2 violation; axe-core flags `aria-allowed- attr` and `aria-required-attr` when `aria-sort` appears on non-button elements.

  4. WCAGWCAG 4.1.2 Name, Role, Value

    Bulk-selection header checkbox uses `aria-checked="mixed"` plus the native `indeterminate` DOM property whenever the selection state is partial.

    Without `aria-checked="mixed"`, AT users hear "checked" or "unchecked" when the truth is "some selected" — the wrong state announcement leads to accidental select-all or accidental deselect-all on activation. WCAG 4.1.2 violation; axe-core flags `aria-valid-attr-value` when the indeterminate state is missing.

  5. APGAPG: Table pattern — scrollable region note

    Horizontally-scrollable tables wrap `<table>` in `<div role="region" aria-labelledby="caption-id" tabindex="0">`. The region role plus accessible name plus `tabindex="0"` make the scrollable area keyboard-discoverable and AT-discoverable.

    Without the wrapper, keyboard users cannot scroll the table — Tab skips past it, arrow keys do nothing. Sighted-mouse users scroll fine; the affordance is invisible to keyboard and AT. WCAG 2.1.1 (Keyboard) violation.

Vocabulary drift

TanStack Table
ColumnDef + SortingState + RowSelectionState
TanStack uses `columnDef.id` for the canonical column identifier and `columnDef.header` (function or string) for the rendered label. Sort state is `Array<{ id, desc: boolean }>`; selection state is `Record<rowId, boolean>`. Naming-only divergence — `columnDef.id` maps to canonical `column.id`, `desc: true` maps to `direction: 'desc'`.
React Aria
Table + Column + Row + Cell
React Aria uses Column with a `key` prop as the column identifier; sort state surfaces via `aria-sort` on the Column. Selection state uses `useTableRowSelectionState` hook. The hook-based selection-state shape matches the canonical `selectionState: Set<RowId>` semantics under different naming.
Material 3
MatTable + MatColumnDef
Angular Material uses `MatColumnDef` directives — each column is a directive with `matCellDef` and `matHeaderCellDef` templates. Density is `density: 'standard' | 'comfortable' | 'compact'` on the table; canonical Table normalises to `comfortable | compact` (Material's "standard" maps to canonical `comfortable`). Variant divergence is naming-only.
Atlassian Design System
DynamicTable
Atlassian uses `isCompact: boolean` instead of a density enum. Maps to canonical `density: 'compact'` when true, `density: 'comfortable'` (default) when false. `rows` and `head` are the data shape; selection via `selectedRowIndex` array. Dynamic column-resize is opt-in.
Carbon
DataTable
Carbon uses `size: 'compact' | 'short' | 'normal' | 'tall' | 'xl'` — five density values vs the canonical two. `compact` and `short` collapse to canonical `compact`; `normal` / `tall` / `xl` collapse to canonical `comfortable`. `headers` and `rows` are the data shape; selection via `selectedIds` set.
Shopify Polaris
IndexTable + DataTable
Polaris ships two table patterns — IndexTable for bulk-action collections (canonical `selectable` variant), DataTable for read-only tabular data (canonical `default` variant). `condensed: boolean` maps to canonical `density: 'compact'`. Mobile condensed mode collapses to the stacked-card layout per the canonical responsive rules.
GitHub Primer
DataTable
Primer DataTable (experimental in @primer/react) ships columns with `header`, `sortBy`, `align`, `width`. Density via `cellPadding: 'condensed' | 'normal' | 'spacious'` — three values mapping to canonical `compact | comfortable` (canonical drops the third value). Naming-only divergence.
Both

Common mistakes

Blocker

#table-no-caption

Table has no `<caption>` and no `aria-labelledby`

Problem

The table ships without an accessible name. SR users entering the table region hear "table, N rows, M columns" but no semantic label — they cannot tell whether they have entered the inventory table, the order-history table, or some unrelated grid. Multi-table pages become navigable only by trial and error.

Fix

Author `<caption>` as the first child of `<table>`. The caption text names the table semantically ("Open issues", "Quarterly revenue by region"). For visually- hidden captions, use sr-only CSS — never omit the element. Acceptable fallback: `aria-labelledby` on `<table>` pointing to a sibling heading id, but `<caption>` is canonical because it is part of the table's structural model.

Blocker

#table-th-no-scope

Header cells use `<td>` or omit `scope="col"`

Problem

Column headers are rendered as `<td>` cells styled to look like headers, or as `<th>` without `scope="col"`. The programmatic header-to-cell association breaks — data cells announce their value but not the column name. Users navigating cell-by-cell hear "1042, 2024- 03-15, paid" with no context for what each value represents.

Fix

Use `<th scope="col">` for column headers and `<th scope="row">` for row headers (when present). Scope is canonical even on simple tables where the browser could infer it — explicit scope is robust against complex tables (multi-row headers, spanning cells) where the implicit algorithm fails.

Blocker

#table-sort-not-button

Sort affordance is a clickable icon without `<button>`

Problem

Sortable columns ship a click-handler on a `<span>` or `<svg>` element. Keyboard users cannot activate the sort — Tab does not land on the icon, Space and Enter do nothing. AT users hear no button announcement; the affordance is invisible to non-mouse users. WCAG 4.1.2 Name-Role-Value violation.

Fix

Wrap the column label plus sort indicator in `<button>`. The button's accessible name is the column name; the sort direction lives on the parent `<th>`'s `aria-sort`. Activating the button fires the sortChange event and updates `aria-sort`. Default browser button styling can be overridden while preserving the semantic.

Major

#table-header-checkbox-no-mixed

Bulk-selection header checkbox lacks the indeterminate state

Problem

The select-all checkbox in the header row toggles only between checked and unchecked. When the user selects some rows individually, the header checkbox shows "unchecked" or "checked" arbitrarily — sighted users cannot tell whether activating it will deselect their partial selection or extend it, and AT users hear the wrong state.

Fix

Set `element.indeterminate = true` AND `aria-checked="mixed"` whenever some rows are selected and others are not. The native indeterminate glyph (typically a horizontal bar) renders automatically; AT announces "mixed". On activation, cycle mixed → all-selected → none-selected per the canonical contract, and clear indeterminate.

Major

#table-scroll-region-not-focusable

Horizontally-scrollable table is not keyboard-reachable

Problem

The table overflows its inline container with `overflow-x: auto` on the table itself or on a parent with neither `tabindex` nor `role="region"`. Keyboard users cannot scroll the table — Tab skips past it, arrow keys do nothing. Sighted-mouse users scroll fine; the affordance is invisible to keyboard and AT users.

Fix

Wrap the table in `<div role="region" aria-labelledby="caption-id" tabindex="0">` whenever horizontal overflow is possible. The region role plus accessible name lets AT users locate the area; `tabindex="0"` makes it focusable so arrow keys scroll it. Visible focus ring on focus-visible signals the scrollable boundary.

Major

#table-virtualization-without-rowcount-aria

Virtualized table omits `aria-rowcount` and `aria-rowindex`

Problem

The table renders only the rows in the viewport plus a buffer (50 of 10000); without `aria-rowcount` on `<table>` and `aria-rowindex` per `<tr>`, AT reports "table, 50 rows" — the user cannot tell that the dataset has 10000 records, and Page Down navigation becomes unreliable because the virtualizer recycles DOM nodes between renders.

Fix

On the `<table>` element set `aria-rowcount="10000"` (true total). On each rendered `<tr>` set `aria-rowindex` to the 1-based true index (not the rendered offset). The virtualizer keeps these in sync as it recycles rows. AT reports the real dataset size and Page Down navigation works against the logical structure.