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.

Highlight
Fig 1.1 · Table · Designer view
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
Designer

Token usage per slot

caption
color
  • foregroundcolor.text.primary
typography
  • sizetext.md
  • weightweight.semibold
thead
color
  • backgroundcolor.surface.sunken
  • bordercolor.border.subtle
th-col
color
  • foregroundcolor.text.primary
typography
  • sizetext.sm
  • weightweight.semibold
td
typography
  • sizetext.sm
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.
Designer

Motion

TransitionDuration token
sortIndicatormotion.duration.fast
rowExpandmotion.duration.base
skeletonLoadingmotion.duration.slow
Easing
motion.easing.standard
Reduced motion
Instant (jump cut)
Designer

Responsive behaviour

BreakpointChange
breakpoint.smBelow 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.mdAt 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.lgAt 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.
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

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

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
Both

Figma↔Code mismatches

  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

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.
Designer

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.

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.