v1.0

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.

Pro

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.HeaderTable.Column, Table.BodyTable.RowTable.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

pnpm dlx @create-ui/cli add table

Anatomy

Table.Container owns the scroll port, the border, the radius, and the density context. Table itself carries the collection props.

<Table.Container>
  <Table>
    <Table.Header>
      <Table.SelectionColumn />
      <Table.Column />
    </Table.Header>
    <Table.Body>
      <Table.Row>
        <Table.SelectionCell />
        <Table.Cell>
          <Table.CellContent />
        </Table.Cell>
      </Table.Row>
    </Table.Body>
    <Table.Footer />
  </Table>
</Table.Container>

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.

ShapeWhat it rendersReach for it when
Table.CellContentleading, title, badge, description, trailingThe cell has a label, or pairs a graphic with a value
Bare children of Table.CellWhatever you put there, laid out in a rowThe cell is a standalone control or status
Table.Cell with variantThe child takes the box instead of the paddingA thumbnail (media) or a sparkline (trend)

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

import { Table, useTableSelection } from "@/components/ui/table"
<Table.Container>
  <Table aria-label="Team members">
    <Table.Header>
      <Table.Column id="name" isRowHeader>
        Member
      </Table.Column>
      <Table.Column id="role">Role</Table.Column>
    </Table.Header>
    <Table.Body items={members}>
      {(member) => (
        <Table.Row id={member.id}>
          <Table.Cell>{member.name}</Table.Cell>
          <Table.Cell>{member.role}</Table.Cell>
        </Table.Row>
      )}
    </Table.Body>
  </Table>
</Table.Container>

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.

Pro
md · row dividers
Customer
Invoice
Status
Amount
Ayla Karagöz
Ayla Karagöz
Billing contact
INV-2041
Paid
$1,240.00
Marcus Okafor
Marcus Okafor
Billing contact
INV-2042
Pending
$860.00
Mei Lin Chen
Mei Lin Chen
Billing contact
INV-2043
Paid
$2,310.00
sm · grid dividers
Customer
Invoice
Status
Amount
Ayla Karagöz
Ayla Karagöz
INV-2041
Paid
$1,240.00
Marcus Okafor
Marcus Okafor
INV-2042
Pending
$860.00
Mei Lin Chen
Mei Lin Chen
INV-2043
Paid
$2,310.00
md · ghost header · no dividers
Customer
Invoice
Status
Amount
Ayla Karagöz
Ayla Karagöz
Billing contact
INV-2041
Paid
$1,240.00
Marcus Okafor
Marcus Okafor
Billing contact
INV-2042
Pending
$860.00
Mei Lin Chen
Mei Lin Chen
Billing contact
INV-2043
Paid
$2,310.00

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.

Pro
resizable · widths from props
Environment
Commit
Author
Duration
production
Fix hydration mismatch on the pricing route
Ayla Karagöz
1m 12s
preview
Add column resizing to the data table
Luca Moretti
48s
production
Bump the design token package to 2.4.0
Priya Sharma
2m 03s
fixed · widths from classes
Environment
Commit
Author
Duration
production
Fix hydration mismatch on the pricing route
Ayla Karagöz
1m 12s
preview
Add column resizing to the data table
Luca Moretti
48s
production
Bump the design token package to 2.4.0
Priya Sharma
2m 03s

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.

Pro
Account
Plan
Seats
MRR
Region
Account owner
Renewal
Health
Northwind 1
Enterprise
12
$1,200
EMEA
Ayla Karagöz
2026-01-14
At risk
Northwind 2
Growth
19
$1,540
AMER
Luca Moretti
2026-02-14
Healthy
Northwind 3
Starter
26
$1,880
EMEA
Ayla Karagöz
2026-03-14
Healthy
Northwind 4
Enterprise
33
$2,220
AMER
Luca Moretti
2026-04-14
Healthy
Northwind 5
Growth
40
$2,560
EMEA
Ayla Karagöz
2026-05-14
At risk
Northwind 6
Starter
47
$2,900
AMER
Luca Moretti
2026-06-14
Healthy
Northwind 7
Enterprise
54
$3,240
EMEA
Ayla Karagöz
2026-07-14
Healthy
Northwind 8
Growth
61
$3,580
AMER
Luca Moretti
2026-08-14
Healthy
Northwind 9
Starter
68
$3,920
EMEA
Ayla Karagöz
2026-09-14
At risk
Northwind 10
Enterprise
75
$4,260
AMER
Luca Moretti
2026-10-14
Healthy
Northwind 11
Growth
82
$4,600
EMEA
Ayla Karagöz
2026-11-14
Healthy
Northwind 12
Starter
89
$4,940
AMER
Luca Moretti
2026-12-14
Healthy

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.

Pro
Team
Seats
Monthly spend
Design
18
$1,440.00
Engineering
46
$3,680.00
Marketing
12
$960.00
Sales
31
$2,480.00
Support
24
$1,920.00
Finance
9
$720.00
Operations
15
$1,200.00
Total
155
$12,400.00

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().

Pro
No documents selected
Document
Owner
Size
Brand guidelines.pdf
Ayla Karagöz
8.2 MB
Q3 roadmap.key
Luca Moretti
24.1 MB
Pricing model.xlsx
Priya Sharma
1.4 MB
Launch checklist.md
Kwame Mensah
12 KB

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.

Pro
Product
Category
In stock
Price
Anodized bottle
Drinkware
128
$34.00
Merino beanie
Apparel
64
$42.00
Field notebook
Stationery
12
$18.00
Travel pouch
Bags
7
$56.00

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.

Pro
Stage
Runner
State
Install dependencies
ubuntu-latest
Passed
Typecheck
ubuntu-latest
Passed
Unit tests
ubuntu-latest
Running
Build registry
ubuntu-latest
Queued

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.

GroupCellsBuilt from
Text and numbers3Bare children. numeric right-aligns and uses tabular figures.
Entity7One Table.CellContent, seven leading nodes: icon, avatar, file, card, brand, logo, flag.
Media2Table.Cell variant="media". Only the aspect ratio separates 1:1 from 2:1.
Metrics4Rating and progress in Table.CellContent, both trends in variant="trend".
Status and people3Bare children: one badge, a badge row, an avatar group.
Controls and actions6Bare children: row controls, a select, a text button, an icon-button row, a ButtonGroup, an overflow trigger.
Feedback2A spinner with its message, and an empty cell that still holds the row open.

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:

Partmdsm
Row height64px40px
Cell paddingcomponent-mdcomponent-sm
Gap between cell childrencomponent-smcomponent-xs
Table.CellContent gapcomponent-mdcomponent-sm
media padding and radiuscomponent-sm, component-lgcomponent-xs, component-sm
leading node40px20px
Second linedescriptiondropped
Long textline-clamp-2truncate
Button, ButtonGroupmdsm
Checkbox, radio, switchsmxs
Selectsmxs
Progressmdsm
Ratingmdxs
Standalone Badgesmxs

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.

Pro
Density
Text
Number
Icon and long text
Icon
Avatar
File
Payment method
Brand
Country
Image 1:1
Image 2:1
Rating
Progress
Trend positive
Trend negative
Badge
Badge group
Avatar group
Row controls
Select
Action button
Action buttons
Action button group
Action more
Loading
Spacer
Multiple line(md)
Design system
1,248
Explore our design system for a cohesive and engaging user experience across all platforms.
Default row titleOwner
Here's the default row description.
Ayla Karagöz
Ayla KaragözOwner
Head of Marketing
PDF
createui.pdfUploaded
147.23 MB
Visa ending in 1224Default
Expiry date 08/31
ChatGPTAI
Your AI chatbot for everyday use.
ArcPartner
A browser company.
United States of AmericaUSA
27 Clients & Partners
3.6
14.76%
Verified
DesignDevelopmentMarketing
Ayla Karagöz
Luca Moretti
Mei-Lin Chen
+9
Changes saving…
Density
Text
Number
Icon and long text
Icon
Avatar
File
Payment method
Brand
Country
Image 1:1
Image 2:1
Rating
Progress
Trend positive
Trend negative
Badge
Badge group
Avatar group
Row controls
Select
Action button
Action buttons
Action button group
Action more
Loading
Spacer
Single line(sm)
Design system
1,248
Explore our design system for a cohesive and engaging user experience across all platforms.
Default row titleOwner
Ayla Karagöz
Ayla KaragözOwner
PDF
createui.pdfUploaded
Visa ending in 1224Default
ChatGPTAI
ArcPartner
United States of AmericaUSA
3.6
14.76%
Verified
DesignDevelopmentMarketing
Ayla Karagöz
Luca Moretti
Mei-Lin Chen
+9
Changes saving…

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.

Pro
Endpoint
Event
Status
https://api.acme.dev/hooks/orders
order.created
Delivered
https://api.acme.dev/hooks/refunds
refund.issued
Failed
https://api.acme.dev/hooks/legacy
invoice.paid
Disabled
https://api.acme.dev/hooks/exports
export.ready
Delivered

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.

Pro
Empty
Endpoint
Event
Status
No webhooks yetEndpoints you register will show up here.
Loading
Endpoint
Event
Status
Loading webhooks…

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.

Pro
Order
Customer
Status
Total
ORD-3200
Ayla Karagöz
Paid
$120.00
ORD-3201
Luca Moretti
Pending
$133.00
ORD-3202
Priya Sharma
Refunded
$146.00
ORD-3203
Kwame Mensah
Paid
$159.00
ORD-3204
Ayla Karagöz
Pending
$172.00
ORD-3205
Luca Moretti
Refunded
$185.00
ORD-3206
Priya Sharma
Paid
$198.00
ORD-3207
Kwame Mensah
Pending
$211.00
ORD-3208
Ayla Karagöz
Refunded
$224.00
ORD-3209
Luca Moretti
Paid
$237.00

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.

Pro
Member
Action
Target
Ayla Karagöz
invited
billing
Luca Moretti
removed
workspace
Priya Sharma
promoted
design-system
Marcus Okafor
archived
api-keys
Ayla Karagöz
invited
billing
Luca Moretti
removed
workspace
Priya Sharma
promoted
design-system
Marcus Okafor
archived
api-keys
Ayla Karagöz
invited
billing
Luca Moretti
removed
workspace
Priya Sharma
promoted
design-system
Marcus Okafor
archived
api-keys

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.

KeyDescription
TabEnters and leaves the table. The whole collection is one stop.
Moves focus between rows.
Moves focus between cells, and into a cell's focusable children.
Home EndFirst and last cell in the row; with Ctrl, the first and last row.
Page Up Page DownMoves a viewport of rows at a time.
SpaceToggles selection on the focused row.
EnterFires the row's onAction, or toggles sort on a focused column.
Shift + ↑ Shift + ↓Extends the selection.
Ctrl/⌘ + A EscSelects every row, and clears the selection.
A to ZTypeahead against the row header cell.

ARIA notes:

  • Pass textValue on any Table.Cell whose children are not plain text, or typeahead and row announcements have no string to work with.
  • Sort state is announced from aria-sort on the column plus React Aria's live region. The arrow is aria-hidden decoration.
  • Table.SelectionColumn and Table.SelectionCell take their checkbox labels and indeterminate state from React Aria. Do not add an aria-label of 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 in disabledKeys leaves 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: Enter picks a row up, arrows move it, Enter drops it, and Esc cancels.
  • Column resizing is a mode, not a plain arrow key. walk focus onto the resizer (a visually hidden input[type=range] inside the header cell), Enter starts resizing, then and move the edge in 10px steps, and Enter, Esc, Tab, or Space ends 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:

<Table.Container className="max-h-96 max-w-3xl">…</Table.Container>

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-content with -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, plus data-empty on the body while the collection is empty.
  • Resolved context: data-size on the container, column, row, and cell; data-align, data-pinned, and data-numeric on 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-sorting and data-resizing (column), data-allows-dragging and data-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:

[data-slot="table-cell"][data-pinned="start"] {
  /* … */
}
  • Pagination: the pager that goes beside the table. It cannot live inside a <table>, so it renders as a sibling of Table.Container; the data-table variant is built for that spot.
  • Scroll Area: overlay scrollbars for arbitrary content. Do not wrap a table in it, because Table.Container has to stay the element React Aria measures. Use scrollbar="thin" for the same look.
  • Checkbox: the control Table.SelectionCell renders, installed with the table automatically.
  • Spinner: what Table.Loading renders, 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

PropTypeDefaultDescription
sizeTableSize-Density for this table only. Inherits from Table.Container, and setting it here re-derives dividers.
appearanceTableAppearance-Header fill for this table only. Inherits from Table.Container.
dividersTableDividers-Border model. Re-derived from size when size is set here, otherwise inherited.
aria-labelstring-React Aria. Accessible name. Required unless aria-labelledby names the table.
selectionMode"none" | "single" | "multiple""none"React Aria. Table.SelectionColumn and Table.SelectionCell are never auto-injected; add both yourself.
selectionBehavior"toggle" | "replace""toggle"React Aria. toggle shows the checkboxes, replace selects on row press and hides them.
selectedKeysSelection-React Aria. Controlled selection. Use useTableSelection rather than branching on the "all" string.
defaultSelectedKeysSelection-React Aria. Uncontrolled initial selection.
onSelectionChange(keys: Selection) => void-React Aria. Fires with "all" when the header checkbox toggles select-all, otherwise a Set.
disabledKeysIterable<Key>-React Aria. Rows to disable. They keep their background and drop text, badges, avatars, and progress to the disabled treatment.
disabledBehavior"all" | "selection""all"React Aria. Defaults to all here, not React Aria's selection, which leaves disabled rows focusable and fully coloured.
sortDescriptorSortDescriptor-React Aria. { column, direction }. column is a Key, so run String(column) before indexing a row.
onSortChange(descriptor: SortDescriptor) => void-React Aria. Fires when a column with allowsSorting is pressed.
onRowAction(key: Key) => void-React Aria. Row activation, and one of the three things that make React Aria report data-hovered.
dragAndDropHooksDragAndDropHooks-React Aria. The object from useDragAndDrop. Set renderDropIndicator on it or the indicator is unstyled.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-A Table.Header and a Table.Body, plus an optional Table.Footer.

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

PropTypeDefaultDescription
sizeTableSize"md"Density. sm is a 40px row on component-sm padding, md a 64px row on component-md. Cascades to every part through context.
appearanceTableAppearance"filled"Header fill. filled paints bg-weak, ghost is transparent. A ghost header still paints bg-static while stickyHeader is on.
dividersTableDividersfrom sizeBorder model: "rows" a bottom rule, "grid" the full cell grid, "none" neither. Derived when omitted: sm gives grid, md gives rows.
stickyHeaderbooleantruePins the header while the body scrolls. Only bites when the container has a bounded height, so pair it with a max-h-*.
resizablebooleantrueWraps the table in ResizableTableContainer and gives every Table.Column a resize handle. false renders a plain scroll container.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-The Table. With resizable={false} the container is a plain <div>, so it can hold several, which is how two densities share one scroll port.

Variants

VariantOptionsDefaultDescription
scrollbar"thin" "auto" "hidden""thin"Native scrollbar treatment. thin matches ScrollArea, auto leaves the platform default, hidden removes it. Do not wrap the table in ScrollArea: this container is the scroll port React Aria measures.

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

PropTypeDefaultDescription
alignTableAlign"start"Horizontal alignment of the label. Defaults to "end" when numeric is set.
numericbooleanfalseRight-aligns and switches to the tabular numeric font. Implies align="end".
resizablebooleaninheritedFalls back to the container's resizable. Set false to drop the handle on one column.
pinned"start" | "end"-Freezes the column against that edge during horizontal scroll. Set it on the column and its cells. Offsets default to left-0 / right-0, so pass style to stack a second pinned column.
idKey-React Aria. Required. Cells map to columns positionally, not by id.
isRowHeaderbooleanfalseReact Aria. The cell screen readers announce while moving between rows. Set it on exactly one column.
allowsSortingbooleanfalseReact Aria. Makes the header pressable and surfaces the sort arrow while this column is the sorted one.
defaultWidthColumnSize | null-React Aria. Starting width: a px number or a string like "1fr" or "30%".
minWidthColumnStaticSize | null-React Aria. Lower bound while resizing.
maxWidthColumnStaticSize | null-React Aria. Upper bound while resizing.
widthColumnSize | null-React Aria. Controlled width; pair it with the container's onResize.
textValuestring-React Aria. String used for announcements when the label is not plain text.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode | ((values: ColumnRenderProps) => React.ReactNode)-The column label, or a render function receiving { sortDirection, isResizing, startResize, … }.

Variants

These are the tableColumnVariants axes. All three resolve from context rather than from props, so set them once on Table.Container.

VariantOptionsDefaultDescription
size"sm" "md""md"Label scale and padding, from the container's size.
appearance"filled" "ghost""filled"Header fill, from the container's appearance. ghost plus sticky compounds to bg-static so rows cannot scroll through.
stickytrue falsetruePins the header cell, from the container's stickyHeader.

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

PropTypeDefaultDescription
emptyReact.ReactNode-Sugar for renderEmptyState. Pass a <Table.Empty /> for the standard treatment.
itemsIterable<T>-React Aria. Dynamic collection; pair it with a function child.
dependenciesReadonlyArray<any>-React Aria. Values that invalidate the row cache for dynamic collections.
renderEmptyState(props: TableBodyRenderProps) => React.ReactNode-React Aria. Full control over the empty state. Takes precedence over empty.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode | ((item: T) => React.ReactElement)-Static rows, or a render function when items is set.

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

PropTypeDefaultDescription
idKey-React Aria. Row key. What selection, disabledKeys, and drag and drop address.
columnsIterable<T>-React Aria. Dynamic cells; pair it with a function child.
onAction() => void-React Aria. Row activation. Also turns on data-hovered for a read-only table.
hrefstring-React Aria. Renders the row as a link.
isDisabledbooleanfalseReact Aria. Disables this row. disabledKeys on Table is the usual route.
textValuestring-React Aria. String used for typeahead.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode | ((item: T) => React.ReactElement)-Cells in column order, or a render function when columns is set.

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

PropTypeDefaultDescription
variantTableCellVariant"default"controls is the drag-handle and checkbox gap (component-md at md, component-sm at sm). media drops a padding step and sizes the child to the row for a thumbnail. trend drops the horizontal padding and sizes the child to the row's content box for a full-bleed trend line.
alignTableAlign"start"Horizontal alignment. Defaults to "end" when numeric is set.
numericbooleanfalseRight-aligns and switches to the tabular numeric font. Implies align="end".
pinned"start" | "end"-Freezes the cell against that edge. Must match its column's pinned, or the row colour disappears behind it during scroll.
idKey-React Aria. Cell key.
textValuestring-React Aria. String used for typeahead when the cell holds more than text.
colSpannumber-React Aria. How many columns the cell spans.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode | ((values: CellRenderProps) => React.ReactNode)-Cell content, or a render function receiving { isSelected, isDisabled, isHovered, … }.

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

PropTypeDefaultDescription
leadingReact.ReactNode-Avatar, file badge, flag, payment card, brand mark, logo, thumbnail, or icon. Size it yourself: 40px at md, 20px at sm.
titleReact.ReactNode-The primary line. Takes the whole line, so badge lands on the trailing edge of the text column.
badgeReact.ReactNode-Sits inline after the title, 4px apart.
descriptionReact.ReactNode-The second line, in text-placeholder. Its presence is what makes a row "multiple line"; there is no multiline prop, and at size="sm" it grows the row past its 40px step.
trailingReact.ReactNode-Pushed to the right edge with ml-auto.
truncatebooleantrueEllipsis on the title and description. Needs a bounded width, so give the column one.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-Extra content between the text block and trailing.

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

PropTypeDefaultDescription
iconReact.ReactNode-Glyph above the title, sized to 32px in text-placeholder.
titleReact.ReactNode-Headline.
descriptionReact.ReactNode-Supporting line.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-Content under the description, usually an action button.

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

PropTypeDefaultDescription
labelReact.ReactNode-Text beside the spinner.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-Content after the label.

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

PropTypeDefaultDescription
isLoadingbooleanfalseReact Aria. Whether the next page is in flight. Also what gates the visible row.
onLoadMore() => any-React Aria. Fires when the sentinel scrolls into range.
scrollOffsetnumber1React Aria. How early to fire, as a multiple of the scroll port's height. 1 is one viewport ahead.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode<Table.Loading />Replaces the default spinner row.

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

PropTypeDefaultDescription
stickybooleanfalsePins the footer to the bottom of the scroll port, the way stickyHeader pins the header.
classNamestring-Tailwind classes merged with the component's CVA classes via cn().
childrenReact.ReactNode-Table.Rows of Table.Cells.

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

PropTypeDefaultDescription
sizeTableSizeinheritedDensity. Re-derives dividers when set without one.
appearanceTableAppearanceinheritedHeader fill.
dividersTableDividersfrom sizeBorder model.
stickyHeaderbooleaninheritedRead by Table.Header and by Table.Column's sticky variant.
resizablebooleaninheritedRead by Table.Column to decide whether to render a Table.Resizer.
childrenReact.ReactNode-Anything that reads the context. The provider renders no element of its own.

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.

import { useTableSelection } from "@/components/ui/table"

Options

OptionTypeDefaultDescription
defaultSelectedKeysIterable<string>[]Keys selected on first render. A positional argument, not an options object.

Returns

FieldTypeDescription
selectedKeysSelectionSpread onto Table. "all" while select-all is on, otherwise a Set<string>.
onSelectionChange(keys: Selection) => voidSpread onto Table. Normalises the "all" branch away.
allSelectedbooleanWhether the header checkbox is in its select-all state.
isSelected(key: Key) => booleanTrue for every key while allSelected, otherwise a set lookup.
count(total: number) => numberSelected count. Hand it your row total so the select-all case resolves to a real number.
clear() => voidDrops the selection and the select-all flag.

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.

import { useTableContext } from "@/components/ui/table"

Returns

A TableContextValue.

FieldTypeOutside a providerDescription
sizeTableSize"md"Resolved density.
appearanceTableAppearance"filled"Resolved header fill.
dividersTableDividers"rows"Resolved border model, after the size derivation.
stickyHeaderbooleantrueWhether the header pins.
resizablebooleantrueWhether columns render a resize handle.

Types

type TableSize = "sm" | "md"
type TableAppearance = "filled" | "ghost"
type TableDividers = "rows" | "grid" | "none"
type TableAlign = "start" | "center" | "end"
type TableScrollbar = "thin" | "auto" | "hidden"
type TableCellVariant = "default" | "controls" | "media" | "trend"
 
type TableContextValue = {
  size: TableSize
  appearance: TableAppearance
  dividers: TableDividers
  stickyHeader: boolean
  resizable: boolean
}
 
type UseTableSelection = {
  selectedKeys: Selection
  onSelectionChange: (keys: Selection) => void
  allSelected: boolean
  isSelected: (key: Key) => boolean
  count: (total: number) => number
  clear: () => void
}

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.

PartRendersPurpose
Table.ResizerdivThe drag handle on a column edge, extending React Aria's ColumnResizerProps. Table.Column renders it whenever resizable is on, and it returns null outside a ResizableTableContainer.
Table.Checkbox-The row and select-all checkbox, built on Checkbox. Reads React Aria's selection slot for its state, id, and labels, sizes itself from context, and stops press events reaching the row. checked, defaultChecked, onCheckedChange, disabled, and size are omitted from its props because the slot and context own them.
Table.DragHandlebuttonThe grip that starts a row drag, built on React Aria's Button slot="drag". 20px at both densities; pass children to swap the glyph. Returns null unless dragAndDropHooks is set on the table.
Table.DragSpacerspanA 20px aria-hidden placeholder that keeps the header checkbox aligned with the rows when only some rows are draggable. Takes no props.