Table
Data table built on React Aria with row selection, column sorting, resizable and pinnable columns, a sticky header over horizontal scroll, and drag-and-drop row reordering.
Description
Table renders a real <table> and drives it with React Aria's table collection, so keyboard navigation, typeahead, row selection, sort announcements, and drag-and-drop reordering all come from the accessibility layer rather than from click handlers you write. The parts mirror the markup: Table.Header → Table.Column, Table.Body → Table.Row → Table.Cell.
Two densities: md (taller rows, component-md padding, row dividers) and sm (compact rows, component-sm padding, a full cell grid). Density is a single prop on Table.Container, and the spacing runs on semantic tokens, so it rescales with the theme. The border model follows the density, and Table.CellContent grows a second line the moment you give it a description.
Reach for it for admin lists, dashboards, and any record grid that needs selection, sorting, or resizable columns. A handful of static rows with none of that is cheaper as plain markup, and a list of records with no shared columns is a list rather than a table.
Table is a Create UI Pro component. With a Pro seat, npx @create-ui/cli add table installs it, and the previews here are marked with a Pro badge. It pulls in Checkbox for row selection and Spinner for the loading state automatically.
Installation
Anatomy
Table.Container owns the scroll port, the border, the radius, and the density context. Table itself carries the collection props.
Inside a cell, every composition in the design is one of three shapes. Picking the right one is the whole job; the parts underneath are ordinary components.
Two gap scales, and picking the wrong one is the usual near miss. Bare children of Table.Cell sit at component-sm at md and component-xs at sm, while Table.CellContent opens that one step, to component-md and component-sm. A graphic paired with a label belongs in Table.CellContent even when it sets no title, or a rating and its score, a progress bar and its percentage, and a spinner and its message all render one step tighter than the design.
Usage
Every column needs an id, and exactly one should be the isRowHeader, the cell screen readers announce as you move between rows. Cells map to columns positionally, so the cell order has to match the column order.
Row height is a fixed step in the design, so cell text never wraps to a second line: size the columns to the content you expect, and reach for Table.CellContent (or your own truncate) when you want an ellipsis instead of a clip.
Hover is treated as an affordance rather than decoration: React Aria reports it only on rows that are selectable, draggable, or carry an onAction, and on columns that set allowsSorting. A read-only table shows no hover wash. Add hover:bg-weakest through className on Table.Row if you want it regardless.
Examples
Four groups, in order. The container props that set the look come first: sizes and appearance, column widths, a sticky header, and a totals footer. Then the three interactions React Aria drives: selection, sorting, and drag and drop. The cell catalog covers everything that goes inside a cell, at both densities. The rest are rows that are not plain data rows: disabled, empty and loading, and the two ways to reach the rows you cannot see yet, pagination and infinite scroll.
Sizes and appearance
size lives on Table.Container and cascades to every part through context; the cells and columns take no size prop of their own. dividers is derived from it (md gets row rules, sm gets the full grid), and you can set it explicitly to break the pairing.
appearance is a separate axis. filled is the default bg-weak header; ghost drops the fill and keeps the header's box and its bottom rule. A ghost header still paints an opaque background while stickyHeader is on, because rows would otherwise scroll straight through it.
Column widths
Column resizing is on by default, and Table.Column renders its own resize handle. Widths flow through defaultWidth / minWidth / maxWidth, never through w-* classes, which React Aria's inline width would override anyway. A column with no width of its own resolves to 1fr, so leaving the last one width-free is what lets it absorb the leftover space; defaultWidth="1fr" says the same thing out loud, and it is how two columns split that space between them while the rest stay fixed.
The pair inverts under resizable={false}. There is no ResizableTableContainer then, so React Aria writes no inline width and the width props are ignored, and a w-* class on the column is what sizes it. Bound the whole table with max-w-* on Table.Container rather than sizing every column.
Alignment is its own prop on both the column and the cell: align takes start (the default), center, or end, and numeric is the shorthand for end plus the tabular numeric font.
Sticky header and pinned columns
The container is the scroll port for both axes, so the header only sticks once the container has a bounded height. Give it a max-h-*. Horizontal scroll needs the same treatment on the other axis: in a parent that sizes to its content, cap the container's width or it grows to fit the columns instead of scrolling them. pinned="start" on a matching column and cell freezes it against the left edge while the rest scrolls.
Because the container is the element React Aria measures, do not wrap the table in ScrollArea. Use the scrollbar prop instead: thin (the default) styles the native scrollbar to match, auto leaves the platform default, and hidden removes it.
Totals footer
Table.Footer renders a real <tfoot> after the body, with a top rule and a bg-weakest fill, so a summary row reads as a peer of the header rather than one more record. Its sticky prop pins it to the bottom edge, and like stickyHeader it only bites when the container has a bounded height.
Selection
Set selectionMode on Table, then add Table.SelectionColumn to the header and Table.SelectionCell as each row's first cell. Neither is auto-injected, and adding one without the other drifts the column count. The select-all checkbox, the indeterminate state, and the aria-labels are wired by React Aria.
useTableSelection wraps the "all" | Set<Key> shape React Aria hands back so you never branch on the string yourself. It returns selectedKeys and onSelectionChange to spread onto Table, plus isSelected, count(total), and clear().
Sorting
Mark the sortable columns with allowsSorting and hold a sortDescriptor in state. The arrow appears only on the column that is currently sorted. sortDescriptor.column is a Key, so run it through String() before you index a row object.
Drag and drop
Pass React Aria's useDragAndDrop hooks to Table and render Table.DropIndicator from renderDropIndicator. Table.SelectionCell grows a drag handle on its own once dragging is enabled.
Table.DragHandle and Table.DragSpacer render null while dragging is off, because React Aria only publishes the drag slot then, so a table that never enables it is left with no gap to clean up.
Cell catalog
The design ships twenty-seven cell compositions in seven groups. Only the first three are table specific; the rest is composition you already know.
Two things to watch. Text cells are whitespace-nowrap, so wrapping is opt-in: whitespace-normal line-clamp-2 for two lines, truncate for one clipped line. And a trend line needs variant="trend", which is what gives it a height and lets it bleed to the cell edges; in a default cell it has no height of its own and collapses.
Below, each composition is a named column of a single-row table, in the group order above. Both densities share one Table.Container, so one sideways drag moves them together and their columns stay aligned, and the density label rides along in a pinned="start" cell. size is the only knob, and spacing steps through the semantic tokens rather than fixed pixels, so it rescales with the theme:
Every size in that table is yours to pass: the cell sets the box, never the control. Two things sit out the step down, the drag handle at 20px and the AvatarGroup at size="xs", and so does the badge inline after a title, which is xs at both densities while a standalone badge drops to xs only at sm. A Select is the one control that will not fit an sm row's padding, so that cell drops it with py-component-none and lets the trigger sit against the row height.
Disabled rows
Rows listed in disabledKeys keep their background and drop their text, badges, avatars and progress bars to the disabled treatment. That works because disabledBehavior defaults to all here rather than React Aria's selection; pass disabledBehavior="selection" to get rows that are merely unselectable while staying focusable and fully coloured.
Empty and loading states
empty on Table.Body is sugar for renderEmptyState; pair it with Table.Empty.
For the first load, render skeleton rows rather than a centred spinner. The table already knows its own shape, so placeholder bars hold the column widths and nothing jumps when the data arrives; a spinner collapses the table to one box and then reflows it. Stagger the animation-delay a little so the pulses do not read as one blinking block. Table.Loading is still the right answer where there is no shape to hold, which is what Table.LoadMore renders while it fetches the next page.
Pagination
Pagination cannot live inside a <table>, so it sits next to Table.Container as a sibling. Pagination's data-table variant is built for exactly this footer, with a page input and a per-page select. You compute totalPages from the row count.
Infinite scroll
Table.LoadMore is a sentinel row at the end of the body. React Aria fires onLoadMore when it scrolls into range, and while isLoading is set the row renders Table.Loading in place rather than covering the table. Because it measures against the scroll port, the container needs a bounded height for the same reason a sticky header does.
Rows have to stay siblings of the sentinel, so wrap them in React Aria's Collection instead of passing a function child to Table.Body.
Accessibility
The table is a single tab stop with a roving focus inside it, which is React Aria's grid interaction model rather than one tab stop per cell. Give every Table an aria-label, and set isRowHeader on exactly one column: that is the cell announced as focus moves between rows.
ARIA notes:
- Pass
textValueon anyTable.Cellwhose children are not plain text, or typeahead and row announcements have no string to work with. - Sort state is announced from
aria-sorton the column plus React Aria's live region. The arrow isaria-hiddendecoration. Table.SelectionColumnandTable.SelectionCelltake their checkbox labels and indeterminate state from React Aria. Do not add anaria-labelof your own.- Icon-only controls inside a cell still need their own
aria-label; the cell does not name them. - With the default
disabledBehavior="all", a row indisabledKeysleaves the focus order entirely. Pass"selection"to keep it focusable and merely unselectable. - Drag and drop exposes React Aria's keyboard drag mode from the drag handle:
Enterpicks a row up, arrows move it,Enterdrops it, andEsccancels. - Column resizing is a mode, not a plain arrow key.
←→walk focus onto the resizer (a visually hiddeninput[type=range]inside the header cell),Enterstarts resizing, then←and→move the edge in 10px steps, andEnter,Esc,Tab, orSpaceends it. React Aria describes the handle with "Press Enter to start resizing", so the mode is announced.
Styling
Tailwind override: pass className to merge Tailwind classes with the component's CVA classes (via cn()). Layout overrides belong on the container, which is the element that scrolls:
Data slots and attributes: the component sets these for CSS targeting.
- Structure:
table-container,table,table-header,table-column,table-column-inner,table-column-sort-icon,table-resizer,table-body,table-row,table-cell,table-cell-inner,table-footer. - Cell content:
table-cell-contentwith-leading,-text,-title,-description, and-trailing. - Selection and drag:
table-selection-controls,table-checkbox,table-drag-handle,table-drag-spacer,table-drop-indicator. - States:
table-empty,table-loading,table-load-more, plusdata-emptyon the body while the collection is empty. - Resolved context:
data-sizeon the container, column, row, and cell;data-align,data-pinned, anddata-numericon the column and cell. - React Aria state:
data-hovered,data-selected,data-disabled,data-focus-visible(column and cell),data-focus-visible-within(row),data-allows-sortinganddata-resizing(column),data-allows-dragginganddata-dragging(row),data-drop-target(indicator).
The container paints the header's fill as a background-image band on top of its bg-static, so an elastic overscroll shows more header instead of flashing the body colour through. Do not replace the container's background with a plain bg-* class while the header is filled, or tailwind-merge drops the colour and strands the band. appearance="ghost" removes the band along with the fill.
tableContainerVariants and tableColumnVariants are exported if you want the class recipes directly.
Target a specific state in CSS:
Related Components
- Pagination: the pager that goes beside the table. It cannot live inside a
<table>, so it renders as a sibling ofTable.Container; thedata-tablevariant is built for that spot. - Scroll Area: overlay scrollbars for arbitrary content. Do not wrap a table in it, because
Table.Containerhas to stay the element React Aria measures. Usescrollbar="thin"for the same look. - Checkbox: the control
Table.SelectionCellrenders, installed with the table automatically. - Spinner: what
Table.Loadingrenders, installed with the table automatically.
API Reference
Every part is exported twice: as a member of the Table namespace (Table.Column) and as a flat component (TableColumn). The headings below use the dotted form, which is what the examples and the rest of this page use.
The collection, selection, sorting, and drag-and-drop behaviour comes from React Aria Table. Props marked React Aria are inherited and behave exactly as they do there; everything else is added by Create UI. The tables list the inherited props worth knowing about rather than restating the whole collection API.
On the React Aria backed parts (Table, Table.Header, Table.Column, Table.Body, Table.Row, Table.Cell, Table.DropIndicator), className also accepts a render function, because it runs through composeRenderProps. Density, appearance, and the border model flow through context, so Table.Column, Table.Cell, and Table.CellContent take no size of their own.
Table re-declares only the three context values a section can sensibly override: size, appearance, and dividers. stickyHeader, resizable, and scrollbar describe the scroll port, so they exist on Table.Container alone.
Table
The collection root. Renders a real <table data-slot="table"> and carries the selection, sorting, and drag-and-drop props. Extends React Aria's TableProps, and opens its own context provider so a section can override the density set on a shared Table.Container.
Props
Table.Container
The scroll port, the border, the radius, and the root of the density context. Renders a <div data-slot="table-container" data-size>. While resizable is on, that div is React Aria's ResizableTableContainer, so its onResize, onResizeStart, and onResizeEnd pass through; with resizable={false} it renders a plain <div> and those three are dropped.
Props
Variants
Table.Header
Holds the columns. Renders a <thead data-slot="table-header"> and extends React Aria's TableHeaderProps<T> without adding props of its own. It reads stickyHeader from context and pins itself when that is on, which is why the max-h-* belongs on Table.Container. For dynamic columns, pass React Aria's columns and a function child.
Table.Column
A header cell. Renders a <th data-slot="table-column" data-size data-align data-pinned data-numeric> and wraps its label in a <div data-slot="table-column-inner"> beside the sort arrow and the resize handle. Extends React Aria's ColumnProps, widening children to accept a render function of ColumnRenderProps.
Widths flow only through defaultWidth / minWidth / maxWidth / width, and only while resizable is on: React Aria writes an inline pixel width that beats any w-* class. Leave at least one column width-free so it absorbs the leftover space.
Props
Variants
These are the tableColumnVariants axes. All three resolve from context rather than from props, so set them once on Table.Container.
Table.Body
Holds the rows. Renders a <tbody data-slot="table-body"> and picks up React Aria's data-empty when the collection is empty, which strips the generated row's cell chrome. Extends React Aria's TableBodyProps<T>.
Props
Table.Row
A row, and the single owner of the state background: hover, selected, selected-plus-hover, dragging, and the focus ring all paint here so the cells can stay transparent. A disabled row is the exception, because the design keeps its background: the row only takes the not-allowed cursor, and the fade on text, badges, avatars, and progress cascades from Table.Cell. Renders a <tr data-slot="table-row" data-size> and extends React Aria's RowProps<T> without adding props of its own.
Props
Table.Cell
A body cell. Renders a <td data-slot="table-cell" data-size data-align data-pinned data-numeric> and wraps its children in a <div data-slot="table-cell-inner"> that is whitespace-nowrap, so text clips rather than wrapping. Extends React Aria's CellProps, widening children to accept a render function of CellRenderProps.
Props
Table.CellContent
The one parameterized content shape behind the design's text-bearing cells: swapping the leading node covers the icon, avatar, file badge, payment card, brand mark, logo, and country cells with a single component. Renders a <div data-slot="table-cell-content"> with -leading, -text, -title, -description, and -trailing sub-slots. Extends Omit<React.ComponentProps<"div">, "title"> and reads size from context for its gap.
Props
Table.SelectionColumn / Table.SelectionCell
The checkbox column and the checkbox cell. Neither is auto-injected. Put Table.SelectionColumn in the header and Table.SelectionCell first in every row yourself, or the column count drifts.
Table.SelectionColumn is a Table.Column with resizable={false} and its width pinned on all three axes to the control it holds: 44px at md and 32px at sm, plus another 32px and 28px once dragAndDropHooks is set. It accepts Omit<TableColumnProps, "children" | "align" | "numeric">, so pinned, id, and the width props still pass through, though overriding a width defeats the point.
Table.SelectionCell is a Table.Cell with variant="controls" holding a Table.DragHandle and a Table.Checkbox. It accepts Omit<TableCellProps, "children" | "align" | "numeric" | "variant">.
Both render their checkbox only while React Aria reports selectionBehavior: "toggle", and the drag handle only while dragging is enabled, so neither leaves a gap in a table that does not use them.
Table.DropIndicator
The line drawn between rows during a drag. Renders a zero-height <tr data-slot="table-drop-indicator"> so it never shifts the rows it sits between, and paints its bar from data-drop-target. Extends React Aria's DropIndicatorProps, so target is required. It is never placed by hand: return it from useDragAndDrop's renderDropIndicator, as in renderDropIndicator: (target) => <Table.DropIndicator target={target} />. Omit that and React Aria renders its own unstyled indicator.
Table.Empty
The empty-state block. Renders a centred <div data-slot="table-empty"> and extends React.ComponentProps<"div">. Pass it to Table.Body's empty, which forwards it to React Aria's renderEmptyState.
Props
Table.Loading
A spinner with an optional message. Renders a <div data-slot="table-loading"> and sizes its Spinner from context (lg at md, sm at sm). This is what Table.LoadMore renders by default; for a first load use skeleton rows instead, since a centred spinner collapses the table to one box and reflows every column when the data arrives.
Props
Table.LoadMore
The infinite-scroll sentinel, placed as the last child of the body. It calls onLoadMore when it nears the bottom of the scroll port. Extends React Aria's TableLoadMoreItemProps. Rows have to stay siblings of it, so wrap a dynamic collection in React Aria's Collection rather than passing a function child to Table.Body.
It renders two rows, and only one of them is ever visible: a zero-height inert <tr> holding the intersection sentinel, always; and the <tr data-slot="table-load-more"> carrying className and children, only while isLoading is true. So the spinner row appears and disappears with the flag rather than sitting under the table.
Props
Table.Footer
A summary or totals row. Renders a <tfoot data-slot="table-footer"> with a bg-weakest fill and a top rule, and drops the bottom border from its cells. Extends React Aria's TableFooterProps<T>, so it holds Table.Rows and supports items for dynamic content. Its className is a plain string: React Aria does not give the footer render props.
Props
Table.Provider
Context only, no DOM. Table.Container and Table each render one internally, so you rarely place it yourself; reach for it to set the density around a fragment that is not a whole table, such as a toolbar that renders a Table.CellContent. Every value falls back to the nearest provider above and then to the defaults, and setting size here re-derives dividers unless you set that too, which is how a sm table inside an md container still gets the grid look.
Props
useTableSelection
Wraps React Aria's "all" | Set<Key> selection so callers never branch on the string, and keeps the state a Set<string> (React 19's Key includes bigint and will not assign to Set<React.Key>). The "all" branch really does fire, because the header checkbox calls toggleSelectAll. It owns its state, so there is no setter beyond clear(); hold your own Selection if you need to drive the selection from outside.
Options
Returns
useTableContext
Reads the resolved density context: what Table.Container (or the nearest Table.Provider) settled on after inheritance and the size to dividers derivation. Use it in a custom cell part that needs to step down with the table. It takes no arguments and never throws: outside a provider it falls back to the defaults below.
Returns
A TableContextValue.
Types
Selection, Key, SortDescriptor, ColumnSize, and DragAndDropHooks come from react-aria-components. Every part also exports its props type (TableContainerProps, TableColumnProps, TableCellProps, and so on).
Composition parts
Controls that the parts above render for you. Each returns null when the feature that owns it is off, so none of them leaves a gap in a table that does not use it, and none needs placing by hand.
