Table
Last Updated: September 2026Tables render a list of records from column definitions, and own the behaviour that comes with them — pagination, virtualized scrolling, row selection, expandable panels, and pinned columns.
Anatomy
link- 1Selection column
- 2Header
- 3Rows
- 4Pagination
Variants
linkContained
Bordered surface with a pagination footer. Pass the current page's rows as data — the table renders what it is given.
Full page
Fills its parent and scrolls, virtualizing rows by default. Give the parent a definite height.
Selectable rows
Controlled by id. Ticking a box doesn't activate the row, so this composes with onRowClick.
Expandable rows
An expander column reveals a panel of your own under the row. Not available while virtualizing.
Reorderable rows
Drag a row by the handle that floats over its leading edge on hover, or focus the handle and use the arrow keys.
Grouped rows
Groups from the server, with their sizes. The table asks for one group at a time as the end of it nears. A header collapses on click; shift-click collapses or expands every group.
Pinned column
A column frozen against one edge while the rest scroll horizontally. One column per side.
Loading
isLoading replaces the rows with placeholders — one page's worth on contained, 20 on fullPage.
Sizes
linkRow height is uniform per size, which is what lets the virtualizer position rows without measuring them.
Usage Guidelines
linkWhen to use
- •Any list of records with columns — this is the default table for product surfaces.
- •Long lists: the fullPage variant virtualizes and can fetch as it scrolls.
- •When rows need checkboxes, an expandable panel, a frozen column, or click-through navigation.
When not to use
- •For bespoke tabular markup — spanning cells, a nested grid, a header that isn't a row of labels. Use TablePrimitives.
- •For a handful of key-value pairs — a description list reads better than a two-column table.
- •For layout. A table is for data that is genuinely tabular.
Define columns once — at module scope, or with useMemo. A fresh array each render rebuilds the table.
Don't set truncate on a content-sized column. Pair it with minmax(0, 1fr) or a fixed width, or it never clips.
When a row navigates, put a real link in one cell too — a row is announced as a row, not as a link.
Don't leave stale rows on screen while the first page loads. isLoading replaces them; isFetchingNextPage is what appends.
Behaviors
linkData and columns
- •Both references should be stable — module scope or useMemo. The table rebuilds its models when either changes.
- •createTableColumns is the whole column API: accessor for a key, compute for a derived value, display for a column with no value.
- •width is a single grid track (120, "1fr", "minmax(0, 1fr)"). A virtualized table is limited to px and fr.
Paging and scrolling
- •contained is paginated and renders the rows you pass — slice the page yourself.
- •fullPage virtualizes by default; pass virtualized={false} for a short list that wants content-based widths.
- •Spread fetchNextPage, isFetchingNextPage, and hasNextPage from useInfiniteQuery to load as the list scrolls; it also fires on mount, so a first page shorter than the viewport keeps loading.
Rows
- •selection is controlled by id and adds the leading checkbox column. Shift-click selects the range since the last click.
- •getRowId is the table's one row identity, shared by everything that has to name a row — both selection and onRowReorder require it.
- •onRowReorder reports a move as the two indexes it was between — into data as passed, so on a paginated table they are positions within the page. The handle is a column on fullPage and floats over the row on contained; either way the arrow keys move a focused row one place.
- •expanding adds an expander column and renders your panel under the open row; it is barred on a virtualized table because a panel's height is its content's.
- •grouping replaces data on a fullPage table: the groups and their sizes come first, and the table asks for one group at a time as the end of it nears. A group header collapses on click, and shift-click takes every group the same way.
- •getRowHref takes precedence over onRowClick; use onRowClick for client-side routing.
Content Guidelines
linkColumns
- •Short header labels in sentence case; put the unit in the header, not in every cell.
- •Lead with the column that identifies the row, and keep actions last.
Empty and loading
- •Say what is missing and what to do about it rather than showing an empty grid with no explanation.
- •Keep the header visible while loading so the layout doesn't shift when rows arrive.
Accessibility
linkText & Labels
- •Name the table from the surrounding heading with aria-labelledby when the heading isn't adjacent.
- •The selection column labels itself (Select row, Select all rows); keep an identifying column so those rows are distinguishable.
ARIA attributes
- •The header checkbox reports mixed while only some rows are selected.
- •The body is aria-busy while placeholders stand in for rows.
- •The expander button carries aria-expanded, so the panel's state is announced.
Keyboard Support
- •Rows become focusable when onRowClick or getRowHref is set, and activate on Enter.
- •Checkboxes, expanders, and controls inside cells are in the normal tab order.
- •Pagination controls are buttons, reachable without a pointer.
WCAG Compliance Standards
FAQs
linkDoes the contained variant slice my data?
No. Pass the current page's rows and the totals in pagination. Keeping the slice at the call site is what lets the same props work against a server-paged query.
Why can't I expand rows on a virtualized table?
Virtualization positions every row from one exact height, and a panel is as tall as its content. Pass virtualized={false} on a fullPage table to use expanding there.
Why is my auto-width column narrow on a fullPage table?
Virtualized rows are positioned out of flow and can't share the table's tracks, so widths must be px or fr. Either give the column a real track or turn virtualization off.
Can rows be reordered while the table is virtualized?
Yes — unlike expanding, reordering needs no row to be a height other than the uniform one. The drop target is the row under the pointer, and dragging to an edge scrolls the list.
How many columns can I pin?
One per side. A second would need the first's resolved pixel width, which fr and auto tracks can't give without measuring the DOM.