Table

Rows and columns with sorting, filtering, selection, expansion, fixed headers and columns, and its own pagination.

Accessibility

Table renders real table markup, so the basics are right. What you add is the name of the table, the name of each row, the names of the checkboxes, and an announcement when the rows change.

What Table renders

Table is a real <table>, with <th> header cells and <td> body cells and no extra roles. Screen readers treat it like any HTML table, so what is left to you is what HTML cannot infer.
  • A header cell is tied to its column only when it has a title. A column with no title, like a selection or actions column, gets a <th> with no scope.
  • Mark the column that names each record. Set rowScope: "row" on it. Without that, a reader moving across a row hears the values but not which record they belong to.
  • Name the table. caption renders a real <caption>. Or put a heading beside the table and point aria-labelledby at it. A visually hidden caption is fine when a visible heading already says the same.

Sorting is state, not an arrow

The sort arrows are hidden from screen readers. The state is on the header cell instead.
  • aria-sort is set only while a column is sorted, as "ascending" or "descending". An unsorted column has none.
  • A sortable column announces “sortable” from the locale's sortable string. A custom locale without it leaves the column silent about sorting.
  • The header cell is focusable and announced as a column header, not a button. Its name is the text of title.
  • Enter sorts. Space does not. That differs from every real button on the page.
  • Keep title as words. For an icon or a badge in the header, add it through onHeaderCell so the name stays readable.

Naming the selection checkboxes

Each selection checkbox comes with a name, and it rarely fits your data.
  • A row checkbox is named Select row {index + 1}, or Row {index + 1} selected once checked. The header one is Select all, or Custom selection with selections.
  • The number counts the rows on the current page, so the first row of page two is “row 1” again, and sorting renames every checkbox.
  • These names are English only. ConfigProvider locale does not translate them.
  • Fix both with getCheckboxProps: (record) => ({ "aria-label": `Select ${record.name}` }), and getTitleCheckboxProps for the header checkbox.
  • When you disable a row's checkbox in getCheckboxProps, say why in the same place.

The expand control

  • The default expand control is a real button with aria-expanded and a name from the locale, “Expand row” or “Collapse row”. It needs nothing from you.
  • It has no aria-controls. The panel it opens is the next row, so keep the detail there rather than elsewhere on the page.
  • Every row's button has the same name. Where that matters, pass your own expandable.expandIcon that names the record.
  • expandRowByClick makes the row clickable, but a row is not focusable. The button still works from the keyboard, and the row click works for the mouse only.
  • Nested rows (childrenColumnName) are flat rows with an indent, and nothing announces their depth. Put the hierarchy in the cell text where it matters.

Keyboard reach, and a scrolled region

Table is not a grid: there are no arrow keys between cells. Tab walks the focusable elements in order, so a table of rows with buttons has a tab stop per button.
  • A body that scrolls sideways is not focusable. Outside Firefox, keyboard users cannot scroll it. Give the wrapper tabIndex={0} and a name, or keep the table on screen with responsive columns.
  • The same goes for the box scroll.y creates. sticky lets the page scroll instead, which avoids the problem.
  • Keep row actions few. Three icon buttons on fifty rows is a hundred and fifty tab stops. One button that opens a menu is fifty.
  • Pinned columns keep their place in the DOM, so focus reaches a column pinned to the end in its declared position.
  • virtual keeps only the visible rows in the DOM, so find in page, select all and a screen reader's read-through stop there. A virtual table needs its own search and filters.

An empty table, and changes nobody hears

Table has no live region, so any announcement is one you add.
  • An empty table shows the locale's emptyText (“No data”) in a row across every column. It can be found, but it is not announced when it appears.
  • Write a better one. locale.emptyText takes a node, so an empty filter result can say which filter emptied it and offer to clear it.
  • Sorting, filtering and paging change the rows with nothing announced. A screen reader user hears the header they pressed and nothing else.
  • Add a polite live region beside the table and fill it from onChange: extra.currentDataSource has the count and extra.action the reason, like "12 rows, sorted by latency".
  • loading covers the rows with a spinner and keeps them in the DOM. Say what is loading in the live region too.

Contrast and motion

  • Table has no zebra striping. If you add stripes with rowClassName, check the text against the stripe, the hover fill and the selected fill: all three need 4.5:1.
  • A pinned column is separated from the scrolled rows by a shadow, which fades as contrast drops. Check it in dark mode.
  • The active sort arrow differs from the inactive one by color alone. aria-sort carries the state, so do not restyle the header until the arrow is the only sign.
  • A selected row that is also hovered gets one combined fill, and the row text has to clear it too.
  • To turn off the table's transitions, set token: { motion: false } in both the light and dark configs of a ThemeScope. Put anything you animate inside a cell behind motion-safe:.