Designer 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.
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 |
Token usage per slot
caption- color
- foreground
color.text.primary
- foreground
- typography
- size
text.md - weight
weight.semibold
- size
thead- color
- background
color.surface.sunken - border
color.border.subtle
- background
th-col- color
- foreground
color.text.primary
- foreground
- typography
- size
text.sm - weight
weight.semibold
- size
td- typography
- size
text.sm
- size
Figma ↔ Code property map
| Figma | Kind | Code | Notes |
|---|---|---|---|
Variant | Enum | variant | Maps default / selectable. Selectable adds the leading checkbox column with mixed-state header. |
Density | Enum | density | comfortable / compact. Drives row-padding and font-size. |
Sticky Header | Boolean | stickyHeader | When true, `<thead>` uses `position: sticky` so header stays visible during vertical scroll. |
Striped | Boolean | striped | Alternating row backgrounds for visual scannability of dense tables. |
Bordered | Boolean | bordered | Visible cell borders. Default is borderless with whitespace-only separation per Polaris / Material 3 convention. |
Hoverable | Boolean | hoverable | Row highlight on hover. Default true; disable for non-interactive presentational tables. |
Sortable | Boolean | sortable | Per-column sort-button affordance plus `aria-sort` on header cells. Multi-sort via the multiSort property. |
Multi Sort | Boolean | multiSort | When true, multiple columns can carry non-`none` `aria-sort` simultaneously; sort precedence is implementation-defined. |
Resizable | Boolean | resizable | Per-column resize-handle inside `<th>`; columnResize event fires on drag-end. |
Virtualization | Boolean | virtualization | Render only viewport-visible rows; requires `aria-rowcount` on `<table>` and `aria-rowindex` per rendered `<tr>`. |
Caption | Slot | caption | Accessible name for the table; visually hidden via sr-only when context already names it. |
Empty State | Slot | emptyState | Renders inside `<tbody>` as a colspan-full row when the dataset is empty. |
Toolbar | Slot | toolbar | Optional region above the table for filter / search / bulk-action controls; rendered as a sibling of `<table>` outside the scrollable-region. |
Motion
| Transition | Duration token |
|---|---|
sortIndicator | motion.duration.fast |
rowExpand | motion.duration.base |
skeletonLoading | motion.duration.slow |
Responsive behaviour
| Breakpoint | Change |
|---|---|
breakpoint.sm | Below this width, the canonical pattern collapses each row to a stacked-card layout — column headers become per-card labels next to the cell value, rows stack vertically. Polaris IndexTable's condensed mobile mode is the precedent. Horizontal scroll is the fallback when stacked-card is not feasible (financial spreadsheets, dense comparisons), but always wrapped in the scrollable-region with focus ring. |
breakpoint.md | At and above this width, the table renders with full horizontal layout; sticky-first-column may be authored for tables wider than the viewport (the leading row-identity column stays visible while other columns scroll horizontally). Sticky header activates when row-count exceeds the viewport vertically. |
breakpoint.lg | At and above this width, the full table is typically visible without horizontal scroll for common column counts (5-12 columns). The scrollable-region wrapper still ships defensively in case the table widens via column-resize or long content; the wrapper does not affect layout when no overflow occurs. |
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).
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 |
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.
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.
Accessibility hints
| 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. |