Dev 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.
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 |
Variants, properties, states
Variants
Structurally different versions of the component.
default selectable Properties
The same component, parameterised.
| Property | Type |
|---|---|
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).
| Kind | States |
|---|---|
interactive | hoverfocus-visibleactive |
data | defaultloadingemptyerrorhasSelectionhasSorting |
State transitions
| From | To | Trigger |
|---|---|---|
default | loading | User activates a filter / sort / pagination control that requires async data fetch |
loading | default | Async fetch resolves with rows |
loading | empty | Async fetch resolves with zero rows |
loading | error | Async fetch rejects |
default | hasSelection | User toggles a row checkbox or activates select-all |
default | hasSorting | User activates a sort-button on a column header |
Cross-framework expression
| Framework | Structure mechanism | Variant 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. |
Events
sortChangeselectionChangerowClickoptionalexpandChangeoptionalcolumnResizeoptionalpaginationChangeoptional
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.
Performance thresholds
virtualizationThresholdrow-count≥100rowsAbove ~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-budget≥16ms`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-feedback≥200msFrom 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.
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. |
Accessibility acceptance
Keyboard walk
| Keys | Expected |
|---|---|
Tab | Focus 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+Tab | Reverse 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
| Trigger | Expected |
|---|---|
| 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 table | SR 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 cell | SR 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-button | SR 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-header | SR 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-attraria-required-childrenaria-required-parentaria-valid-attr-valuecolor-contrastscope-attr-validtd-headers-attrth-has-data-cellsempty-table-headertable-fake-caption
Same data as JSON for direct ingestion into Playwright + @axe-core/playwright or Jest + jest-axe:
/api/components/table/a11y-fixture.json
Contracts
Non-negotiable contracts
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.
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`.
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.
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.
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.
Common mistakes
#table-no-caption
Table has no `<caption>` and no `aria-labelledby`
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.
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.
#table-th-no-scope
Header cells use `<td>` or omit `scope="col"`
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.
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.
#table-sort-not-button
Sort affordance is a clickable icon without `<button>`
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.
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.
#table-header-checkbox-no-mixed
Bulk-selection header checkbox lacks the indeterminate state
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.
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.
#table-scroll-region-not-focusable
Horizontally-scrollable table is not keyboard-reachable
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.
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.
#table-virtualization-without-rowcount-aria
Virtualized table omits `aria-rowcount` and `aria-rowindex`
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.
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.
Figma↔Code mismatches
- 01 Figma
Table title styled as a heading floating above the table frame
Code`<caption>` element rendered inside `<table>` (programmatic accessible name)
ConsequenceDesigners 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.
CorrectDocument 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.
- 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
ConsequenceDesigners 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.
CorrectDocument 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`).
- 03 Figma
Header bulk-checkbox drawn with binary checked / unchecked states
CodeNative `<input type="checkbox">` with `indeterminate` DOM property plus `aria-checked="mixed"`
ConsequenceDesigners 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".
CorrectDocument 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.
- 04 Figma
Horizontal scroll designed as silent overflow (no visible boundary)
Code`<div role="region" tabindex="0" aria-labelledby>` wrapping `<table>`
ConsequenceDesigners 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.
CorrectDocument 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.
- 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>`
ConsequenceDesigners 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".
CorrectDocument 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.