DataTable

Organism
A data table rendered from column and row data, with sorting, row selection, pagination, and the loading and empty states built in.

Playground

Every control sets a real prop of DataTableProps. The JSX below updates as you go.
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Props
bordered
boolean
striped
boolean
Density
pick one
selectable
boolean
sortable
boolean
loading
boolean
JSX · DataTableProps
<DataTable bordered sortable />

Variants

16 examples from src/organisms/data-table/data-table.md, each rendered live from its own JSX.
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Bordered
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
bordered
/>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Attached
A standalone table frames itself: a hairline at the table corner over the card fill, its header band on the raised fill under it. bordered draws that frame on the firmer field boundary. A table that sits flush inside a frame its parent draws (a flush Card, a bordered panel that clips to its corners) passes attached, so it draws no frame of its own and the parent's is the only one. An attached table draws its keyboard focus ring just inside its edge, where the parent's clip cannot cut it.
<Card flat flush style={{ overflow: "hidden" }}>
<DataTable
attached
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
/>
</Card>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Dan Wright
dan@example.com
Viewer
Striped
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"],
["Dan Wright", "dan@example.com", "Viewer"]
]}
striped
/>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Compact
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
compact
/>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Comfortable
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
comfortable
/>
Name
Email
Status
Loading
<DataTable
columns={["Name", "Email", "Status"]}
rows={[]}
loading
/>
Name
Email
Status
No results found.
Empty
<DataTable
columns={["Name", "Email", "Status"]}
rows={[]}
emptyMessage="No results found."
/>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Sortable
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Rachel Chen", "rachel@example.com", "Admin"],
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"]
]}
sortable
defaultSort={{ column: "Name" }}
/>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
Selectable
<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
selectable
defaultSelectedKeys={[1]}
/>
Employee
Team
Employee 1
Design
Employee 2
Platform
Employee 3
Growth
Employee 4
Design
Employee 5
Platform
Showing 1-5 of 23
Paginated
<DataTable
columns={["Employee", "Team"]}
rows={Array.from({ length: 23 }, (_, i) => [
`Employee ${i + 1}`,
["Design", "Platform", "Growth"][i % 3]
])}
paginated
pageSize={5}
/>
Invoice
Status
Amount
INV-0041
Paid
$1,250.00
INV-0042
Due
$450.00
INV-0043
Draft
$8,120.00
Numeric and custom columns
<DataTable
columns={[
"Invoice",
{ label: "Status", centered: true, width: 120 },
{ label: "Amount", numeric: true }
]}
rows={[
["INV-0041", <Badge success>Paid</Badge>, "$1,250.00"],
["INV-0042", <Badge warning>Due</Badge>, "$450.00"],
["INV-0043", <Badge neutral>Draft</Badge>, "$8,120.00"]
]}
/>
Page
7D
Visits
/pricing
12,480
/docs
8,102
/blog
5,914
Sparkline trend column
<DataTable
columns={["Page", "7D", "Visits"]}
rows={[
["/pricing", <Sparkline values={[52, 60, 55, 71, 68, 84, 96]} />, "12,480"],
["/docs", <Sparkline values={[88, 74, 70, 66, 58, 49, 41]} />, "8,102"],
["/blog", <Sparkline values={[34, 42, 39, 51, 60, 66, 78]} />, "5,914"]
]}
/>
Member
Role
AJ
Alice Johnson
Admin
BS
Bob Smith
Editor
RC
Rachel Chen
Admin
Avatar identity cells
<DataTable
columns={["Member", "Role"]}
rows={[
[<MediaObject compact avatar="AJ" title="Alice Johnson" />, "Admin"],
[<MediaObject compact avatar="BS" title="Bob Smith" />, "Editor"],
[<MediaObject compact avatar="RC" title="Rachel Chen" />, "Admin"]
]}
/>
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Viewer
Row actions
<Stateful initial={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Viewer"]
]}>
{(rows, setRows) => (
<DataTable
columns={["Name", "Email", "Role"]}
rows={rows}
onRowEdit={() => {}}
onRowCommit={(i, cells) => setRows(rows.map((row, r) => (r === i ? cells.map(String) : row)))}
onRowDelete={(i) => setRows(rows.filter((_row, r) => r !== i))}
/>
)}
</Stateful>
Page
Visits
Inline editing
<Stateful initial={[
["/pricing", "12,480"],
["/docs", "8,102"],
["/blog", "5,914"]
]}>
{(rows, setRows) => (
<DataTable
columns={["Page", "Visits"]}
rows={rows}
inlineEdit
onCellCommit={(i, c, next) =>
setRows(rows.map((row, r) => (r === i ? row.map((cell, ci) => (ci === c ? next : cell)) : row)))
}
/>
)}
</Stateful>
Setting
Default
Description
Session timeout
30 minutes
Signs a member out after this long without activity. Shorter timeouts suit shared devices; longer ones suit a personal laptop.
Password length
12 characters
The shortest password a member can choose. Longer minimums resist guessing, and a passphrase meets them easily.
Recovery codes
10 codes
One-time codes a member can use when their authenticator is unavailable. Each code works once.
Stacked
Pass stacks when the cells carry long text. When the table's own container is at or below stackBreakpoint (default sm, 640), each row lays its cells out top to bottom at the full width: the first cell leads as the row's title and every later cell sits under its column label, so a paragraph reads at the table's width instead of a column's share of it. The header row stays only while it holds a control (a sortable column, as here, or the box that selects every row), the selection box and row actions keep their places, and on iOS the table stacks instead of collapsing to its first column. Above the breakpoint it is the ordinary table.
<DataTable
stacks
sortable
columns={["Setting", "Default", "Description"]}
rows={[
["Session timeout", "30 minutes", "Signs a member out after this long without activity. Shorter timeouts suit shared devices; longer ones suit a personal laptop."],
["Password length", "12 characters", "The shortest password a member can choose. Longer minimums resist guessing, and a passphrase meets them easily."],
["Recovery codes", "10 codes", "One-time codes a member can use when their authenticator is unavailable. Each code works once."]
]}
/>

States

Hover, focus and press are pinned so they can be compared side by side; the rest are real props.
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
default
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
hover
Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
focus

Anatomy

Name
Email
Role
Alice Johnson
alice@example.com
Admin
Bob Smith
bob@example.com
Editor
Rachel Chen
rachel@example.com
Admin
1
Table
2
Header row
3
Sort indicator
4
Row
5
Cell
6
Selection
Dimmed parts appear only in some states or variants.

Props

43 props on DataTableProps, grouped as they are declared in source. Required props are marked *.
Prop
Type
Default
Description
Props
8 props
columns *
Array<string | DataTableColumn>
–
Columns: a header label per column, or a DataTableColumn descriptor for per-column alignment (numeric/centered), a fixed width, and sorting (sortable, sortValue). Strings and descriptors mix freely. Treat the array and descriptors as immutable: replace both when a column changes.
rows
ReactNode[][]
–
Row data: an array of rows, each an array of cells (one per column). A cell is a string (rendered in the default cell type) or any ReactNode for a custom cell, such as a link, a Badge, or a monospace name (rendered directly). Treat this data as immutable: replace the outer array and each changed row when data changes, so cached sorting and pagination update correctly.
groups
DataTableGroup[]
–
Group the rows under titled bands (the props table's "Intent", "Size" and "State" groups): each group draws a band spanning every column, its label, an optional adornment and an optional count on the sunken fill, then its own rows. Given groups, the table's rows are the groups' rows in order and rows is ignored; every row index a callback reports (onRowPress, onRowEdit, rowKey and the rest) counts through them in that order. Sorting reorders rows within their group and never moves a group; paging pages the rows, and each page draws the band of every group it shows. A group with no rows draws no band.
striped
boolean
false
Tint every other data row for easier horizontal scanning.
bordered
boolean
false
Draw the table's frame on the firmer field boundary rather than the hairline.
attached
boolean
false
The table sits flush inside a frame its parent draws (a flush Card, a bordered panel that clips to its corners), so it draws no frame of its own. Without it a standalone table frames itself (on the web and Android, a hairline at the table corner over the card fill), which is the standalone look.
stacks
boolean
false
Responsive: when the table's own CONTAINER is at or below stackBreakpoint (default sm = 640), each row lays its cells out top to bottom at the full width instead of side by side, so long text reads at the width of the table rather than a column's share of it. The first cell leads the row as its title; every later cell carries its column label above its value. The header row is drawn only while it holds a control (a sortable column, or the box that selects every row), as a band of its labels that wraps; the selection box and the row actions stay at the row's leading and trailing edges. A stacked table never pans, and on iOS it stacks instead of collapsing to its first column. Container-measured with a viewport seed for the first frame, like Row stacks; crossing the breakpoint changes the layout only.
stackBreakpoint
BreakpointKey
–
The breakpoint at and below which stacks stacks the rows (default "sm"). Only meaningful with stacks (DEV warns without it).
Density
pick one
35 props
compact
boolean
false
Tighter vertical padding on header and data cells.
comfortable
boolean
false
Looser vertical padding on header and data cells.
reference
boolean
false
The reference sheet (a component page's props table): the column headings as the page's small uppercase mono label on a header row with no fill of its own, 12 px above and below whatever the density; rows 10 px above and below with a rule above each; the columns 16 px apart and 16 px in from the frame's sides; and the frame at the panel corner on the border hairline over the card fill, so bordered only firms the hairline and attached still drops the frame. On iOS the table keeps its own look and this changes nothing.
bare
boolean
false
The bare hairline table: no frame and no fill, a field-border rule under the headings (the reference sheet's mono uppercase label) and a border rule under every row, the first column flush with the table's start, each cell keeping 12 px clear at its end inside its column's width, and 12 px above and below whatever the density. reference wins over it. On iOS the table keeps its own look and this changes nothing.
matrix
boolean
false
The specimen matrix (a section page's states grid): no frame, no fill and no rules, the headings as the page's small uppercase mono label in the third ink, the cells unpadded, the rows 12 px apart and the columns 20 px apart. Pair it with fit columns for the hand-off's content-sized tracks. reference and bare win over it. On iOS the table keeps its own look and this changes nothing.
alignTop
boolean
false
Align every cell to the top of its row rather than its middle, so a row whose description wraps keeps its name, type and default beside the description's first line. The selection box and the row actions center on that first line, as they do in a stacked row.
footer
ReactNode
–
A row drawn inside the table's frame under the last row (and under the pagination bar when paginated): 44 px tall with its content centered, for a "Show all" control or a note. A hairline separates it from what sits above it: the one the last row draws already, or its own where nothing above it draws one.
sortable
boolean
false
Make every column sortable: pressing a header cycles ascending, descending, off. A column descriptor opts back out with sortable: false. Sorting is self-managed out of the box; control it via sort/onSortChange.
sort
DataTableSort | null
–
Controlled sort state (null = unsorted). Omit for uncontrolled use.
defaultSort
DataTableSort | null
–
Initial sort for uncontrolled use.
onSortChange
(sort: DataTableSort | null) => void
–
Fired with the next sort (or null when cleared) on header press.
selectable
boolean
false
Row selection: a leading checkbox column with a select-all header checkbox (indeterminate on a partial selection). Selection is keyed by rowKey (the row's original index when omitted) and self-managed out of the box; control it via selectedKeys/onSelectionChange. When no onRowPress is set, pressing anywhere on a row toggles it.
selectedKeys
Array<string | number>
–
Controlled selected row keys. Omit for uncontrolled use.
defaultSelectedKeys
Array<string | number>
–
Initial selected row keys for uncontrolled use.
onSelectionChange
(keys: Array<string | number>) => void
–
Fired with the next selected keys when a row or the select-all is toggled.
paginated
boolean
false
Page the rows through a built-in footer (the kit Pagination): a "Showing X-Y of N" range with Prev/Next, plus the selection count when selectable. Sorting applies before paging. Self-managed out of the box; control the page via page/onPageChange.
pageSize
number
–
Rows per page (CONTROLLED; default 10, or the first of pageSizes).
defaultPageSize
number
–
Initial rows per page for uncontrolled use.
pageSizes
number[]
–
Selectable rows-per-page values. Supplying them upgrades the footer to the Pagination withSize variant (a "Rows per page" selector).
onPageSizeChange
(size: number) => void
–
Fired with the next rows-per-page value when the footer selector changes it.

Accessibility and keyboard

Keyboard
Arrow keys
Moves between cells
Home
End
Row start · end
PageUp
PageDown
Previous · next page
Enter
Space
Activates or selects
Semantics
Roles
button
cell
columnheader
row
rowgroup
table
ARIA on web
aria-colspan
aria-label
aria-sort
From the docs
Columns are plain header labels or descriptors ({ label, numeric, width, sortable, ... }) for per-column alignment, fixed widths, and sorting; compose a toolbar above when the screen needs one. Density tweaks affect padding live. Pass onRowEdit / onRowDelete for a trailing actions column: the pencil opens the row's string cells as fields with Save/Cancel (committing via onRowCommit), and Delete asks for a confirming second press before it fires. inlineEdit skips the pencil and lets a string cell be presse
On narrow web and Android layouts, the columns still share the table width and wrap their text, each keeping a 110 px minimum (a column with a fixed width keeps that width), and they pan horizontally only when those minimums exceed the table width. A long cell wraps inside its column rather than widening the table. The overflowing scrollport is a keyboard tab stop, so the platform's arrow keys can reveal the remaining columns, and while it has keyboard focus the table's surface draws the theme's focus ring. A tab
A virtualized table's body scrolls its rows under the fixed header. On the web, while those rows overflow, the body is a keyboard tab stop of its own (after the column scroller when a narrow table pans, and before the rows' controls when they have any), the keyboard can scroll it, and while it has keyboard focus the table's surface draws the same focus ring. The body is the table's row group, so the rows it renders stay inside the table for a screen reader rather than behind an unnamed focusable node.

Platform skins

The playground's props on each platform: same tree, native shapes, type and touch sizes.
Default
Disabled
primary
outline
Web
control 10 · field 10 · card 16 · Instrument Sans
Default
Disabled
primary
outline
iOS
capsule controls · field 8 · card 12 · SF Pro · 44 pt
Default
Disabled
primary
outline
Android
Material 3 stadium · field 4 · card 12 · Roboto · 48 dp

Usage

<DataTable
columns={["Name", "Email", "Role"]}
rows={[
["Alice Johnson", "alice@example.com", "Admin"],
["Bob Smith", "bob@example.com", "Editor"],
["Rachel Chen", "rachel@example.com", "Admin"]
]}
/>
Columns are plain header labels or descriptors ({ label, numeric, width, sortable, ... }) for per-column alignment, fixed widths, and sorting; compose a toolbar above when the screen needs one. Density tweaks affect padding live. Pass onRowEdit / onRowDelete for a trailing actions column: the pencil opens the row's string cells as fields with Save/Cancel (committing via onRowCommit), and Delete asks for a confirming second press before it fires. inlineEdit skips the pencil and lets a string cell be pressed straight into a field, committing via onCellCommit. onRowPress makes the whole row act on press and puts the keyboard and screen-reader path on a button in the row's first column, so keep your own controls out of that column when you use it. Data stays yours either way: the table reports intent and re-renders whatever rows it is handed back.
Treat rows and columns as immutable inputs. When editing data, replace the outer rows array and each changed row, as the examples below do. When changing a column, replace its descriptor and the columns array. Keep unchanged arrays and callbacks stable for large tables: Canvas caches sorting, paging, and selection summaries so typing a draft or opening a row action does not process the whole data set again. sortValue and rowKey must be pure functions of their inputs. If either closes over changing external data, supply a new callback identity; for sortValue, also replace its column descriptor and the columns array. A saved edit appears in sorted order when the updated rows arrive.
On narrow web and Android layouts, the columns still share the table width and wrap their text, each keeping a 110 px minimum (a column with a fixed width keeps that width), and they pan horizontally only when those minimums exceed the table width. A long cell wraps inside its column rather than widening the table. The overflowing scrollport is a keyboard tab stop, so the platform's arrow keys can reveal the remaining columns, and while it has keyboard focus the table's surface draws the theme's focus ring. A table that fits adds no scrolling tab stop, and on Android it takes no sideways drag either, so a tap that drifts sideways still presses its row and a swipe that starts across it scrolls the page (while TalkBack explores by touch the scroller stays enabled, so the exploring finger reaches every cell). On an iPhone-width table iOS shows only the first column instead, as SwiftUI Table does. A table that passes stacks does neither: at that width each row stacks its cells (see Stacked below). On the web and Android, and for a table that stacks on any platform, crossing that width changes the layout only: rows, custom cells, an open editor with its text and focus, and a windowed body's scroll position stay as they are when a window is resized across it or a phone measures the table on its first frame. The iOS first-column layout is the exception: below that width the columns after the first leave the table, with any editor or custom cell in them, and mount again when it widens.

Tokens

Read from the playground render. Values follow the scheme and the ion hue.
--bad
--bad-soft
--fd
--fm
--ft
--ion-soft
--ion-tx
--ln
--ok
--ok-soft
--sf
--tx2
--tx3

Related components