Arcwell Health arcwell.test | Enterprise | Active | $9,800 | Mar 18, 2026 | Priya Shah | |
Bower Supply bowersupply.test | Starter | Trial | $720 | Nov 18, 2026 | Priya Shah | |
Copperline copperline.test | Growth | Active | $4,700 | May 18, 2026 | Maya Chen | |
Daybreak Energy daybreak.test | Enterprise | Active | $15,100 | Jun 18, 2026 | Noah Williams | |
Fieldnote Studio fieldnote.test | Starter | Paused | $890 | Apr 18, 2026 | Jon Bell | |
Goodweather goodweather.test | Growth | Active | $5,400 | Dec 18, 2026 | Jon Bell | |
Kite & Harbor kiteharbor.test | Growth | Trial | $3,200 | Feb 18, 2026 | Noah Williams | |
Morrow Works morrow.test | Growth | Paused | $2,800 | Sep 18, 2026 | Maya Chen |
'use client'
import { Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
renewal: string
owner: string
}
const accounts: Account[] = [
{
id: 'acc_01',
company: 'Northstar Labs',
domain: 'northstar.test',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
renewal: '2026-01-18',
owner: 'Maya Chen',
},
{
id: 'acc_02',
company: 'Kite & Harbor',
domain: 'kiteharbor.test',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
renewal: '2026-02-18',
owner: 'Noah Williams',
},
{
id: 'acc_03',
company: 'Arcwell Health',
domain: 'arcwell.test',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
renewal: '2026-03-18',
owner: 'Priya Shah',
},
{
id: 'acc_04',
company: 'Fieldnote Studio',
domain: 'fieldnote.test',
plan: 'Starter',
status: 'Paused',
mrr: 890,
renewal: '2026-04-18',
owner: 'Jon Bell',
},
{
id: 'acc_05',
company: 'Copperline',
domain: 'copperline.test',
plan: 'Growth',
status: 'Active',
mrr: 4700,
renewal: '2026-05-18',
owner: 'Maya Chen',
},
{
id: 'acc_06',
company: 'Daybreak Energy',
domain: 'daybreak.test',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
renewal: '2026-06-18',
owner: 'Noah Williams',
},
{
id: 'acc_07',
company: 'Plainspoken',
domain: 'plainspoken.test',
plan: 'Starter',
status: 'Trial',
mrr: 640,
renewal: '2026-07-18',
owner: 'Priya Shah',
},
{
id: 'acc_08',
company: 'Orbit Commerce',
domain: 'orbitcommerce.test',
plan: 'Growth',
status: 'Active',
mrr: 6250,
renewal: '2026-08-18',
owner: 'Jon Bell',
},
{
id: 'acc_09',
company: 'Morrow Works',
domain: 'morrow.test',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
renewal: '2026-09-18',
owner: 'Maya Chen',
},
{
id: 'acc_10',
company: 'Tandem Security',
domain: 'tandemsecurity.test',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
renewal: '2026-10-18',
owner: 'Noah Williams',
},
{
id: 'acc_11',
company: 'Bower Supply',
domain: 'bowersupply.test',
plan: 'Starter',
status: 'Trial',
mrr: 720,
renewal: '2026-11-18',
owner: 'Priya Shah',
},
{
id: 'acc_12',
company: 'Goodweather',
domain: 'goodweather.test',
plan: 'Growth',
status: 'Active',
mrr: 5400,
renewal: '2026-12-18',
owner: 'Jon Bell',
},
]
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: ({ header }) => <header.SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('renewal', {
header: 'Renewal',
cell: ({ cell }) => <cell.DateCell />,
size: 140,
sortFn: 'text',
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTableBasic() {
const table = useDataTable({
columns,
data: accounts,
enableRowSelection: true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 8 },
sorting: [{ id: 'company', desc: false }],
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts" />
</table.ScrollArea>
<table.Pagination />
</table.Root>
</table.Provider>
)
}
Usage
The DataTable is built on TanStack Table v9. The
useDataTable hook creates a table instance with all parts, cell components and
header components bound to it, so you never import the parts separately.
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'Define your columns with createDataTableColumnHelper. The cell components are
bound to the column definition, which keeps the cells typed and lets them read
the value from the table context.
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => <cell.NumberCell style="currency" currency="USD" />,
}),
columnHelper.accessor('renewal', {
header: 'Renewal',
cell: ({ cell }) => <cell.DateCell month="short" day="numeric" />,
}),
])Then compose the parts. table.Table renders the header and body for you, wrap
it in table.ScrollArea to make it scrollable.
const table = useDataTable({ columns, data })
return (
<table.Provider>
<table.Root>
<table.ScrollArea>
<table.Table aria-label="Accounts" />
</table.ScrollArea>
<table.Pagination />
</table.Root>
</table.Provider>
)Compose the header and body explicitly when you need to customize them.
<table.Table aria-label="Accounts">
<table.Header />
<table.Body />
</table.Table>useDataTable accepts all TanStack Table options, like enableRowSelection,
getSubRows, initialState and state with on[Feature]Change handlers for
controlled state. Sorting, global and column filtering, pagination, row
selection, column visibility, column pinning and column resizing are enabled out
of the box.Examples
Striped
Use the striped prop to add zebra-stripes to the rows, the size prop to
change the density of the table, and the variant prop to switch between the
line and outline appearance.
Northstar Labs | Enterprise | Active | $12,400 | Maya Chen |
Kite & Harbor | Growth | Trial | $3,200 | Noah Williams |
Arcwell Health | Enterprise | Active | $9,800 | Priya Shah |
Fieldnote Studio | Starter | Paused | $890 | Jon Bell |
Copperline | Growth | Active | $4,700 | Maya Chen |
Daybreak Energy | Enterprise | Active | $15,100 | Noah Williams |
Plainspoken | Starter | Trial | $640 | Priya Shah |
Orbit Commerce | Growth | Active | $6,250 | Jon Bell |
Morrow Works | Growth | Paused | $2,800 | Maya Chen |
Tandem Security | Enterprise | Active | $17,600 | Noah Williams |
'use client'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const accounts: Account[] = [
{
id: 'acc_01',
company: 'Northstar Labs',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
owner: 'Maya Chen',
},
{
id: 'acc_02',
company: 'Kite & Harbor',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
owner: 'Noah Williams',
},
{
id: 'acc_03',
company: 'Arcwell Health',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
owner: 'Priya Shah',
},
{
id: 'acc_04',
company: 'Fieldnote Studio',
plan: 'Starter',
status: 'Paused',
mrr: 890,
owner: 'Jon Bell',
},
{
id: 'acc_05',
company: 'Copperline',
plan: 'Growth',
status: 'Active',
mrr: 4700,
owner: 'Maya Chen',
},
{
id: 'acc_06',
company: 'Daybreak Energy',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
owner: 'Noah Williams',
},
{
id: 'acc_07',
company: 'Plainspoken',
plan: 'Starter',
status: 'Trial',
mrr: 640,
owner: 'Priya Shah',
},
{
id: 'acc_08',
company: 'Orbit Commerce',
plan: 'Growth',
status: 'Active',
mrr: 6250,
owner: 'Jon Bell',
},
{
id: 'acc_09',
company: 'Morrow Works',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
owner: 'Maya Chen',
},
{
id: 'acc_10',
company: 'Tandem Security',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
owner: 'Noah Williams',
},
]
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTableStriped() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 20 },
},
})
return (
<table.Provider>
<table.Root size="sm" striped variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts, striped" />
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Interactive Rows
Use the onRowClick prop on table.Body to make rows clickable. Rows become
keyboard-interactive as well.
Northstar Labs northstar.test | Enterprise | Active | $12,400 | Maya Chen | |
Kite & Harbor kiteharbor.test | Growth | Trial | $3,200 | Noah Williams | |
Arcwell Health arcwell.test | Enterprise | Active | $9,800 | Priya Shah | |
Fieldnote Studio fieldnote.test | Starter | Paused | $890 | Jon Bell | |
Copperline copperline.test | Growth | Active | $4,700 | Maya Chen | |
Daybreak Energy daybreak.test | Enterprise | Active | $15,100 | Noah Williams | |
Plainspoken plainspoken.test | Starter | Trial | $640 | Priya Shah | |
Orbit Commerce orbitcommerce.test | Growth | Active | $6,250 | Jon Bell |
'use client'
import { Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const accounts: Account[] = [
{
id: 'acc_01',
company: 'Northstar Labs',
domain: 'northstar.test',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
owner: 'Maya Chen',
},
{
id: 'acc_02',
company: 'Kite & Harbor',
domain: 'kiteharbor.test',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
owner: 'Noah Williams',
},
{
id: 'acc_03',
company: 'Arcwell Health',
domain: 'arcwell.test',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
owner: 'Priya Shah',
},
{
id: 'acc_04',
company: 'Fieldnote Studio',
domain: 'fieldnote.test',
plan: 'Starter',
status: 'Paused',
mrr: 890,
owner: 'Jon Bell',
},
{
id: 'acc_05',
company: 'Copperline',
domain: 'copperline.test',
plan: 'Growth',
status: 'Active',
mrr: 4700,
owner: 'Maya Chen',
},
{
id: 'acc_06',
company: 'Daybreak Energy',
domain: 'daybreak.test',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
owner: 'Noah Williams',
},
{
id: 'acc_07',
company: 'Plainspoken',
domain: 'plainspoken.test',
plan: 'Starter',
status: 'Trial',
mrr: 640,
owner: 'Priya Shah',
},
{
id: 'acc_08',
company: 'Orbit Commerce',
domain: 'orbitcommerce.test',
plan: 'Growth',
status: 'Active',
mrr: 6250,
owner: 'Jon Bell',
},
{
id: 'acc_09',
company: 'Morrow Works',
domain: 'morrow.test',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
owner: 'Maya Chen',
},
{
id: 'acc_10',
company: 'Tandem Security',
domain: 'tandemsecurity.test',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
owner: 'Noah Williams',
},
{
id: 'acc_11',
company: 'Bower Supply',
domain: 'bowersupply.test',
plan: 'Starter',
status: 'Trial',
mrr: 720,
owner: 'Priya Shah',
},
{
id: 'acc_12',
company: 'Goodweather',
domain: 'goodweather.test',
plan: 'Growth',
status: 'Active',
mrr: 5400,
owner: 'Jon Bell',
},
]
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: ({ header }) => <header.SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTableInteractiveRows() {
const table = useDataTable({
columns,
data: accounts,
enableRowSelection: true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 8 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts">
<table.Header />
<table.Body
onRowClick={(row) => {
console.log('row click', row.original.company)
}}
/>
</table.Table>
</table.ScrollArea>
<table.Pagination />
</table.Root>
</table.Provider>
)
}
Composable Rows
Pass a render function to table.Body to render your own rows. The body keeps
owning the loop, empty state and virtualization, spread the rowProps argument
into table.Row to keep the layout intact.
Northstar Labs | Enterprise | Active | $12,400 | Maya Chen | |
Kite & Harbor | Growth | Trial | $3,200 | Noah Williams | |
Arcwell Health | Enterprise | Active | $9,800 | Priya Shah | |
Fieldnote Studio | Starter | Paused | $890 | Jon Bell | |
Copperline | Growth | Active | $4,700 | Maya Chen | |
Daybreak Energy | Enterprise | Active | $15,100 | Noah Williams | |
Plainspoken | Starter | Trial | $640 | Priya Shah | |
Orbit Commerce | Growth | Active | $6,250 | Jon Bell | |
Morrow Works | Growth | Paused | $2,800 | Maya Chen | |
Tandem Security | Enterprise | Active | $17,600 | Noah Williams |
'use client'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const accounts: Account[] = [
{
id: 'acc_01',
company: 'Northstar Labs',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
owner: 'Maya Chen',
},
{
id: 'acc_02',
company: 'Kite & Harbor',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
owner: 'Noah Williams',
},
{
id: 'acc_03',
company: 'Arcwell Health',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
owner: 'Priya Shah',
},
{
id: 'acc_04',
company: 'Fieldnote Studio',
plan: 'Starter',
status: 'Paused',
mrr: 890,
owner: 'Jon Bell',
},
{
id: 'acc_05',
company: 'Copperline',
plan: 'Growth',
status: 'Active',
mrr: 4700,
owner: 'Maya Chen',
},
{
id: 'acc_06',
company: 'Daybreak Energy',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
owner: 'Noah Williams',
},
{
id: 'acc_07',
company: 'Plainspoken',
plan: 'Starter',
status: 'Trial',
mrr: 640,
owner: 'Priya Shah',
},
{
id: 'acc_08',
company: 'Orbit Commerce',
plan: 'Growth',
status: 'Active',
mrr: 6250,
owner: 'Jon Bell',
},
{
id: 'acc_09',
company: 'Morrow Works',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
owner: 'Maya Chen',
},
{
id: 'acc_10',
company: 'Tandem Security',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
owner: 'Noah Williams',
},
]
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: ({ header }) => <header.SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
/**
* The Body render prop keeps the body in charge of the row loop, empty state
* and virtualization, while every row is composed at the call site.
*/
export function DataTableComposableRows() {
const table = useDataTable({
columns,
data: accounts,
enableRowSelection: true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 20 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts">
<table.Header />
<table.Body>
{(row, rowProps) => (
<table.Row
row={row}
{...rowProps}
opacity={row.original.status === 'Paused' ? 0.5 : undefined}
onRowClick={(clicked) => {
console.log('custom row click', clicked.original.company)
}}
/>
)}
</table.Body>
</table.Table>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Context Menu
The row render function also lets you wrap rows in other components, like a context menu.
Northstar Labs | Enterprise | Active | $12,400 | Maya Chen | |
Kite & Harbor | Growth | Trial | $3,200 | Noah Williams | |
Arcwell Health | Enterprise | Active | $9,800 | Priya Shah | |
Fieldnote Studio | Starter | Paused | $890 | Jon Bell | |
Copperline | Growth | Active | $4,700 | Maya Chen | |
Daybreak Energy | Enterprise | Active | $15,100 | Noah Williams | |
Plainspoken | Starter | Trial | $640 | Priya Shah | |
Orbit Commerce | Growth | Active | $6,250 | Jon Bell | |
Morrow Works | Growth | Paused | $2,800 | Maya Chen | |
Tandem Security | Enterprise | Active | $17,600 | Noah Williams |
'use client'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
import { Menu } from '#components/ui/menu'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const accounts: Account[] = [
{
id: 'acc_01',
company: 'Northstar Labs',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
owner: 'Maya Chen',
},
{
id: 'acc_02',
company: 'Kite & Harbor',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
owner: 'Noah Williams',
},
{
id: 'acc_03',
company: 'Arcwell Health',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
owner: 'Priya Shah',
},
{
id: 'acc_04',
company: 'Fieldnote Studio',
plan: 'Starter',
status: 'Paused',
mrr: 890,
owner: 'Jon Bell',
},
{
id: 'acc_05',
company: 'Copperline',
plan: 'Growth',
status: 'Active',
mrr: 4700,
owner: 'Maya Chen',
},
{
id: 'acc_06',
company: 'Daybreak Energy',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
owner: 'Noah Williams',
},
{
id: 'acc_07',
company: 'Plainspoken',
plan: 'Starter',
status: 'Trial',
mrr: 640,
owner: 'Priya Shah',
},
{
id: 'acc_08',
company: 'Orbit Commerce',
plan: 'Growth',
status: 'Active',
mrr: 6250,
owner: 'Jon Bell',
},
{
id: 'acc_09',
company: 'Morrow Works',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
owner: 'Maya Chen',
},
{
id: 'acc_10',
company: 'Tandem Security',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
owner: 'Noah Williams',
},
]
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: ({ header }) => <header.SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
/**
* Row context menus composed through the Body render prop: each row is
* wrapped in a menu with a context trigger — right-click a row to open it.
*/
export function DataTableContextMenu() {
const table = useDataTable({
columns,
data: accounts,
enableRowSelection: true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 20 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts">
<table.Header />
<table.Body>
{(row, rowProps) => (
<Menu.Root>
<Menu.ContextTrigger asChild>
<table.Row interactive row={row} {...rowProps} />
</Menu.ContextTrigger>
<Menu.Content>
<Menu.Item
value="view"
onClick={() => console.log('view', row.original.company)}
>
View {row.original.company}
</Menu.Item>
<Menu.Item value="edit">Edit</Menu.Item>
<Menu.Separator />
<Menu.Item color="fg.error" value="delete">
Delete
</Menu.Item>
</Menu.Content>
</Menu.Root>
)}
</table.Body>
</table.Table>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Pinned Columns
Use the columnPinning state to pin columns to the start or end of the
table. Pinned columns stay in place while the table scrolls horizontally.
Northstar Labs northstar.test | Enterprise | Active | $12,400 | Jan 18, 2026 | Maya Chen | |
Kite & Harbor kiteharbor.test | Growth | Trial | $3,200 | Feb 18, 2026 | Noah Williams | |
Arcwell Health arcwell.test | Enterprise | Active | $9,800 | Mar 18, 2026 | Priya Shah | |
Fieldnote Studio fieldnote.test | Starter | Paused | $890 | Apr 18, 2026 | Jon Bell | |
Copperline copperline.test | Growth | Active | $4,700 | May 18, 2026 | Maya Chen | |
Daybreak Energy daybreak.test | Enterprise | Active | $15,100 | Jun 18, 2026 | Noah Williams | |
Plainspoken plainspoken.test | Starter | Trial | $640 | Jul 18, 2026 | Priya Shah | |
Orbit Commerce orbitcommerce.test | Growth | Active | $6,250 | Aug 18, 2026 | Jon Bell | |
Morrow Works morrow.test | Growth | Paused | $2,800 | Sep 18, 2026 | Maya Chen | |
Tandem Security tandemsecurity.test | Enterprise | Active | $17,600 | Oct 18, 2026 | Noah Williams |
'use client'
import { Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
renewal: string
owner: string
}
const accounts: Account[] = [
{
id: 'acc_01',
company: 'Northstar Labs',
domain: 'northstar.test',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
renewal: '2026-01-18',
owner: 'Maya Chen',
},
{
id: 'acc_02',
company: 'Kite & Harbor',
domain: 'kiteharbor.test',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
renewal: '2026-02-18',
owner: 'Noah Williams',
},
{
id: 'acc_03',
company: 'Arcwell Health',
domain: 'arcwell.test',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
renewal: '2026-03-18',
owner: 'Priya Shah',
},
{
id: 'acc_04',
company: 'Fieldnote Studio',
domain: 'fieldnote.test',
plan: 'Starter',
status: 'Paused',
mrr: 890,
renewal: '2026-04-18',
owner: 'Jon Bell',
},
{
id: 'acc_05',
company: 'Copperline',
domain: 'copperline.test',
plan: 'Growth',
status: 'Active',
mrr: 4700,
renewal: '2026-05-18',
owner: 'Maya Chen',
},
{
id: 'acc_06',
company: 'Daybreak Energy',
domain: 'daybreak.test',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
renewal: '2026-06-18',
owner: 'Noah Williams',
},
{
id: 'acc_07',
company: 'Plainspoken',
domain: 'plainspoken.test',
plan: 'Starter',
status: 'Trial',
mrr: 640,
renewal: '2026-07-18',
owner: 'Priya Shah',
},
{
id: 'acc_08',
company: 'Orbit Commerce',
domain: 'orbitcommerce.test',
plan: 'Growth',
status: 'Active',
mrr: 6250,
renewal: '2026-08-18',
owner: 'Jon Bell',
},
{
id: 'acc_09',
company: 'Morrow Works',
domain: 'morrow.test',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
renewal: '2026-09-18',
owner: 'Maya Chen',
},
{
id: 'acc_10',
company: 'Tandem Security',
domain: 'tandemsecurity.test',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
renewal: '2026-10-18',
owner: 'Noah Williams',
},
]
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: ({ header }) => <header.SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('renewal', {
header: 'Renewal',
cell: ({ cell }) => <cell.DateCell />,
size: 140,
sortFn: 'text',
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTablePinnedColumns() {
const table = useDataTable({
columns,
data: accounts,
enableRowSelection: true,
getRowId: (row) => row.id,
initialState: {
columnPinning: { start: ['selection', 'company'], end: ['owner'] },
pagination: { pageIndex: 0, pageSize: 20 },
},
})
return (
<table.Provider>
<table.Root layout="fixed" maxWidth="40rem" variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts with pinned columns" />
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Pagination
Render table.Pagination to add pagination controls. It shows the row range and
previous/next navigation by default.
Arcwell Health arcwellhealth.test | Starter | Paused | $1,074 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 10 arcwellhealth.test | Starter | Paused | $8,472 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 11 arcwellhealth.test | Starter | Paused | $9,294 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 12 arcwellhealth.test | Starter | Paused | $10,116 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 13 arcwellhealth.test | Starter | Paused | $10,938 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 14 arcwellhealth.test | Starter | Paused | $11,760 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 15 arcwellhealth.test | Starter | Paused | $12,582 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 2 arcwellhealth.test | Starter | Paused | $1,896 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 3 arcwellhealth.test | Starter | Paused | $2,718 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 4 arcwellhealth.test | Starter | Paused | $3,540 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 5 arcwellhealth.test | Starter | Paused | $4,362 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 6 arcwellhealth.test | Starter | Paused | $5,184 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 7 arcwellhealth.test | Starter | Paused | $6,006 | Mar 18, 2026 | Priya Shah | |
Arcwell Health 8 arcwellhealth.test | Starter | Paused | $6,828 | Sep 18, 2026 | Maya Chen | |
Arcwell Health 9 arcwellhealth.test | Starter | Paused | $7,650 | Mar 18, 2026 | Priya Shah |
'use client'
import { Stack, Text } from '@chakra-ui/react'
import {
SelectionHeader,
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
renewal: string
owner: string
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
const accounts: Account[] = Array.from({ length: 90 }, (_, index) => {
const name = companies[index % companies.length]!
const batch = Math.floor(index / companies.length) + 1
return {
id: `acc_${String(index + 1).padStart(3, '0')}`,
company: batch === 1 ? name : `${name} ${batch}`,
domain: `${name.toLowerCase().replace(/[^a-z]/g, '')}.test`,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 137) % 18000),
renewal: `2026-${String((index % 12) + 1).padStart(2, '0')}-18`,
owner: owners[index % owners.length]!,
}
})
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: () => <SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableCellSelection: false,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('renewal', {
header: 'Renewal',
cell: ({ cell }) => <cell.DateCell />,
size: 140,
sortFn: 'text',
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTablePagination() {
const table = useDataTable({
columns,
data: accounts,
enableRowSelection: true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 15 },
sorting: [{ id: 'company', desc: false }],
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="40rem">
<table.Table aria-label="Accounts" />
</table.ScrollArea>
<table.Pagination />
</table.Root>
</table.Provider>
)
}
Numbered Pagination
Compose table.Pagination with table.PaginationRange and
table.PaginationPages to show numbered page buttons.
Arcwell Health arcwellhealth.test | Starter | Paused | $1,074 | Priya Shah |
Arcwell Health 10 arcwellhealth.test | Starter | Paused | $8,472 | Maya Chen |
Arcwell Health 11 arcwellhealth.test | Starter | Paused | $9,294 | Priya Shah |
Arcwell Health 12 arcwellhealth.test | Starter | Paused | $10,116 | Maya Chen |
Arcwell Health 13 arcwellhealth.test | Starter | Paused | $10,938 | Priya Shah |
Arcwell Health 14 arcwellhealth.test | Starter | Paused | $11,760 | Maya Chen |
Arcwell Health 15 arcwellhealth.test | Starter | Paused | $12,582 | Priya Shah |
Arcwell Health 2 arcwellhealth.test | Starter | Paused | $1,896 | Maya Chen |
Arcwell Health 3 arcwellhealth.test | Starter | Paused | $2,718 | Priya Shah |
Arcwell Health 4 arcwellhealth.test | Starter | Paused | $3,540 | Maya Chen |
'use client'
import { Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
const accounts: Account[] = Array.from({ length: 90 }, (_, index) => {
const name = companies[index % companies.length]!
const batch = Math.floor(index / companies.length) + 1
return {
id: `acc_${String(index + 1).padStart(3, '0')}`,
company: batch === 1 ? name : `${name} ${batch}`,
domain: `${name.toLowerCase().replace(/[^a-z]/g, '')}.test`,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 137) % 18000),
owner: owners[index % owners.length]!,
}
})
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTableNumberedPagination() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 10 },
sorting: [{ id: 'company', desc: false }],
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="40rem">
<table.Table aria-label="Accounts" />
</table.ScrollArea>
<table.Pagination>
<table.PaginationRange />
<table.PaginationPages />
</table.Pagination>
</table.Root>
</table.Provider>
)
}
Page Select
Use table.PageSelect to let users jump to a page with a select, useful for
tables with many pages.
Arcwell Health arcwellhealth.test | Starter | Paused | $1,074 | Priya Shah |
Arcwell Health 10 arcwellhealth.test | Starter | Paused | $8,472 | Maya Chen |
Arcwell Health 11 arcwellhealth.test | Starter | Paused | $9,294 | Priya Shah |
Arcwell Health 12 arcwellhealth.test | Starter | Paused | $10,116 | Maya Chen |
Arcwell Health 13 arcwellhealth.test | Starter | Paused | $10,938 | Priya Shah |
Arcwell Health 14 arcwellhealth.test | Starter | Paused | $11,760 | Maya Chen |
Arcwell Health 15 arcwellhealth.test | Starter | Paused | $12,582 | Priya Shah |
Arcwell Health 2 arcwellhealth.test | Starter | Paused | $1,896 | Maya Chen |
Arcwell Health 3 arcwellhealth.test | Starter | Paused | $2,718 | Priya Shah |
Arcwell Health 4 arcwellhealth.test | Starter | Paused | $3,540 | Maya Chen |
Page
'use client'
import { HStack, Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
const accounts: Account[] = Array.from({ length: 90 }, (_, index) => {
const name = companies[index % companies.length]!
const batch = Math.floor(index / companies.length) + 1
return {
id: `acc_${String(index + 1).padStart(3, '0')}`,
company: batch === 1 ? name : `${name} ${batch}`,
domain: `${name.toLowerCase().replace(/[^a-z]/g, '')}.test`,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 137) % 18000),
owner: owners[index % owners.length]!,
}
})
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
sortDescFirst: true,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
sortFn: 'text',
}),
])
export function DataTablePageSelect() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 10 },
sorting: [{ id: 'company', desc: false }],
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="40rem">
<table.Table aria-label="Accounts" />
</table.ScrollArea>
<table.Pagination>
<table.PaginationRange />
<HStack gap="3">
<HStack gap="2">
<Text textStyle="sm">Page</Text>
<table.PageSelect />
</HStack>
<table.PaginationNav />
</HStack>
</table.Pagination>
</table.Root>
</table.Provider>
)
}
Manual Pagination
Set manualPagination and rowCount to paginate on the server. The table
leaves the row model alone and calls onPaginationChange when the page changes.
Loading… |
'use client'
import { useEffect, useState } from 'react'
import { Stack, Text } from '@chakra-ui/react'
import {
SelectionHeader,
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
/** Stands in for the rows living on the server. */
const database: Account[] = Array.from({ length: 90 }, (_, index) => {
const name = companies[index % companies.length]!
const batch = Math.floor(index / companies.length) + 1
return {
id: `acc_${String(index + 1).padStart(3, '0')}`,
company: batch === 1 ? name : `${name} ${batch}`,
domain: `${name.toLowerCase().replace(/[^a-z]/g, '')}.test`,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 137) % 18000),
owner: owners[index % owners.length]!,
}
})
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'selection',
header: () => <SelectionHeader />,
cell: ({ cell }) => <cell.SelectionCell />,
enableCellSelection: false,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
}),
])
/**
* Server-side pagination: the table only ever holds the current page.
* `manualPagination` disables client-side slicing, `rowCount` drives the page
* count, and the selector limits re-renders of this component to pagination
* changes, which the effect uses to fetch the next page.
*/
export function DataTableManualPagination() {
const [data, setData] = useState<Account[]>([])
const [loading, setLoading] = useState(true)
const table = useDataTable(
{
columns,
data,
enableRowSelection: true,
getRowId: (row) => row.id,
manualPagination: true,
rowCount: database.length,
initialState: {
pagination: { pageIndex: 0, pageSize: 10 },
},
},
(state) => state.pagination,
)
const { pageIndex, pageSize } = table.state
useEffect(() => {
setLoading(true)
const timeout = setTimeout(() => {
setData(database.slice(pageIndex * pageSize, (pageIndex + 1) * pageSize))
setLoading(false)
}, 400)
return () => clearTimeout(timeout)
}, [pageIndex, pageSize])
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea
maxHeight="40rem"
opacity={loading ? 0.5 : 1}
transition="opacity 0.15s"
>
<table.Table aria-label="Accounts">
<table.Header />
<table.Body emptyState={loading ? 'Loading…' : 'No results'} />
</table.Table>
</table.ScrollArea>
<table.Pagination />
</table.Root>
</table.Provider>
)
}
Footer
Define a footer on a column to render a sticky footer row, for example page
totals. The footer only renders when at least one column defines one; pass
sticky={false} to let it scroll with the rows.
Arcwell Health | Enterprise | $9,800 |
Bower Supply | Starter | $720 |
Copperline | Growth | $4,700 |
Daybreak Energy | Enterprise | $15,100 |
Fieldnote Studio | Starter | $890 |
Goodweather | Growth | $5,400 |
Kite & Harbor | Growth | $3,200 |
Morrow Works | Growth | $2,800 |
Page total | $42,610 |
'use client'
import { FormatNumber, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
}
const accounts: Account[] = [
{
id: '1',
company: 'Northstar Labs',
plan: 'Enterprise',
status: 'Active',
mrr: 12400,
},
{
id: '2',
company: 'Kite & Harbor',
plan: 'Growth',
status: 'Trial',
mrr: 3200,
},
{
id: '3',
company: 'Arcwell Health',
plan: 'Enterprise',
status: 'Active',
mrr: 9800,
},
{
id: '4',
company: 'Fieldnote Studio',
plan: 'Starter',
status: 'Paused',
mrr: 890,
},
{
id: '5',
company: 'Copperline',
plan: 'Growth',
status: 'Active',
mrr: 4700,
},
{
id: '6',
company: 'Daybreak Energy',
plan: 'Enterprise',
status: 'Active',
mrr: 15100,
},
{
id: '7',
company: 'Plainspoken',
plan: 'Starter',
status: 'Trial',
mrr: 640,
},
{
id: '8',
company: 'Orbit Commerce',
plan: 'Growth',
status: 'Active',
mrr: 6250,
},
{
id: '9',
company: 'Morrow Works',
plan: 'Growth',
status: 'Paused',
mrr: 2800,
},
{
id: '10',
company: 'Tandem Security',
plan: 'Enterprise',
status: 'Active',
mrr: 17600,
},
{
id: '11',
company: 'Bower Supply',
plan: 'Starter',
status: 'Trial',
mrr: 720,
},
{
id: '12',
company: 'Goodweather',
plan: 'Growth',
status: 'Active',
mrr: 5400,
},
]
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
footer: () => <Text fontWeight="medium">Page total</Text>,
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
footer: ({ table }) => (
<Text fontWeight="medium" whiteSpace="nowrap">
<FormatNumber
currency="USD"
maximumFractionDigits={0}
style="currency"
value={table
.getRowModel()
.rows.reduce((sum, row) => sum + row.original.mrr, 0)}
/>
</Text>
),
meta: { isNumeric: true },
size: 140,
}),
])
export function DataTableFooter() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 8 },
sorting: [{ id: 'company', desc: false }],
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="24rem">
<table.Table aria-label="Accounts with totals">
<table.Header />
<table.Body />
<table.Footer />
</table.Table>
</table.ScrollArea>
<table.Pagination />
</table.Root>
</table.Provider>
)
}
Empty State
Pass emptyState to table.Body to render custom content when there are no
rows. The built-in table.NoResults reflects the active filters in its message
and clears them on click — pass onReset when filters are managed outside the
table.
No accounts found for "wayfarer" |
'use client'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
mrr: number
}
const accounts: Account[] = [
{ id: '1', company: 'Northstar Labs', plan: 'Enterprise', mrr: 12400 },
{ id: '2', company: 'Kite & Harbor', plan: 'Growth', mrr: 3200 },
{ id: '3', company: 'Arcwell Health', plan: 'Enterprise', mrr: 9800 },
{ id: '4', company: 'Fieldnote Studio', plan: 'Starter', mrr: 890 },
{ id: '5', company: 'Copperline', plan: 'Growth', mrr: 4700 },
{ id: '6', company: 'Daybreak Energy', plan: 'Enterprise', mrr: 15100 },
]
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 140,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 140,
}),
])
export function DataTableNoResults() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
globalFilter: 'wayfarer',
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea>
<table.Table aria-label="Accounts">
<table.Header />
<table.Body
emptyState={<table.NoResults resource="accounts" />}
/>
</table.Table>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Sub Rows
Use getSubRows to render hierarchical data. Add an ExpanderCell to a column
to toggle the child rows.
Northstar Labs | Active | $800 | Maya Chen |
Kite & Harbor | Trial | $2,170 | Noah Williams |
Arcwell Health | Paused | $3,540 | Priya Shah |
Fieldnote Studio | Active | $4,910 | Jon Bell |
Copperline | Trial | $6,280 | Maya Chen |
Daybreak Energy | Paused | $7,650 | Noah Williams |
Orbit Commerce | Active | $9,020 | Priya Shah |
Tandem Security | Trial | $10,390 | Jon Bell |
'use client'
import { Box, HStack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
status: 'Active' | 'Trial' | 'Paused'
mrr: number
owner: string
subsidiaries?: Account[]
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
'Orbit Commerce',
'Tandem Security',
]
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
const regions = ['EU', 'US', 'APAC'] as const
const accounts: Account[] = companies.map((name, index) => {
const domain = `${name.toLowerCase().replace(/[^a-z]/g, '')}.test`
const mrr = 800 + ((index * 1370) % 18000)
return {
id: `acc_${index + 1}`,
company: name,
domain,
status: statuses[index % statuses.length]!,
mrr,
owner: owners[index % owners.length]!,
subsidiaries: regions.slice(0, (index % 3) + 1).map((region, sub) => ({
id: `acc_${index + 1}_${region}`,
company: `${name} ${region}`,
domain: `${region.toLowerCase()}.${domain}`,
status: statuses[(index + sub) % statuses.length]!,
mrr: Math.round(mrr / ((index % 3) + 2)),
owner: owners[(index + sub + 1) % owners.length]!,
})),
}
})
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell, row }) => (
<HStack gap="1" minWidth="0" paddingStart={`${row.depth * 1.5}rem`}>
{row.getCanExpand() ? (
<cell.ExpanderCell />
) : (
<Box flexShrink={0} width="6" />
)}
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
</HStack>
),
size: 280,
sortFn: 'text',
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 120,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 120,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 160,
}),
])
export function DataTableSubRows() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
getSubRows: (row) => row.subsidiaries,
initialState: {
pagination: { pageIndex: 0, pageSize: 50 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="40rem">
<table.Table aria-label="Accounts with subsidiaries" />
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Grouped Rows
Use the grouping state to group rows by a column, and set aggregationFn on
the other columns to aggregate their values.
Enterprise (4) | $27,860 | ||
Northstar Labs | Active | $800 | |
Fieldnote Studio | Active | $4,910 | |
Orbit Commerce | Active | $9,020 | |
Lattice Grove | Active | $13,130 | |
Growth (4) | $33,340 | ||
Kite & Harbor | Trial | $2,170 | |
Copperline | Trial | $6,280 | |
Tandem Security | Trial | $10,390 | |
Signal House | Trial | $14,500 | |
Starter (4) | $38,820 | ||
Arcwell Health | Paused | $3,540 | |
Daybreak Energy | Paused | $7,650 | |
Goodweather | Paused | $11,760 | |
Sable Finance | Paused | $15,870 |
'use client'
import { FormatNumber, HStack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
'Orbit Commerce',
'Tandem Security',
'Goodweather',
'Lattice Grove',
'Signal House',
'Sable Finance',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const accounts: Account[] = companies.map((company, index) => ({
id: `acc_${index + 1}`,
company,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 1370) % 18000),
}))
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell, row }) =>
cell.getIsGrouped() ? (
<HStack gap="1" minWidth="0">
<cell.ExpanderCell />
<Text fontWeight="medium">{String(cell.getValue())}</Text>
<Text color="fg.muted" textStyle="xs">
({row.subRows.length})
</Text>
</HStack>
) : (
<cell.TextCell />
),
size: 200,
}),
columnHelper.accessor('company', {
header: 'Account',
// Cells without an aggregationFn render their regular template on group
// rows, so blank them there explicitly.
cell: ({ cell, row }) => (row.getIsGrouped() ? null : <cell.TextCell />),
size: 220,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell, row }) =>
row.getIsGrouped() ? null : (
<cell.BadgeCell colorPalette={statusPalette} />
),
size: 120,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
aggregationFn: 'sum',
// The default aggregatedCell renders the raw value; format the sum the
// same way as the leaf cells. (No bound components here — the column
// helper only augments cell/header/footer contexts.)
aggregatedCell: ({ getValue }) => (
<Text fontWeight="medium" whiteSpace="nowrap">
<FormatNumber
currency="USD"
maximumFractionDigits={0}
style="currency"
value={getValue<number>() ?? 0}
/>
</Text>
),
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 140,
}),
])
export function DataTableGroupedRows() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
expanded: true,
grouping: ['plan'],
pagination: { pageIndex: 0, pageSize: 50 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="40rem">
<table.Table aria-label="Accounts grouped by plan" />
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Expanded Row
Use getRowCanExpand together with the renderExpandedRow prop on table.Body
to render a full width panel below a row.
Northstar Labs | Enterprise | Active | $800 | |
Kite & Harbor | Growth | Trial | $2,170 | |
Arcwell Health | Starter | Paused | $3,540 | |
Fieldnote Studio | Enterprise | Active | $4,910 | |
Copperline | Growth | Trial | $6,280 | |
Daybreak Energy | Starter | Paused | $7,650 | |
Orbit Commerce | Enterprise | Active | $9,020 | |
Tandem Security | Growth | Trial | $10,390 |
'use client'
import { Box, FormatNumber, HStack, Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
renewal: string
owner: string
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
'Orbit Commerce',
'Tandem Security',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
const accounts: Account[] = companies.map((company, index) => ({
id: `acc_${index + 1}`,
company,
domain: `${company.toLowerCase().replace(/[^a-z]/g, '')}.test`,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 1370) % 18000),
renewal: `2026-${String((index % 12) + 1).padStart(2, '0')}-18`,
owner: owners[index % owners.length]!,
}))
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.display({
id: 'expander',
cell: ({ cell }) => <cell.ExpanderCell />,
enableResizing: false,
enableSorting: false,
maxSize: 40,
minSize: 40,
size: 40,
}),
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 120,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 120,
}),
])
/**
* Master–detail expansion: any row can expand into a custom detail panel
* rendered as a full-width row via `renderExpandedRow`. Uses `getRowCanExpand`
* since the rows have no sub-rows.
*/
export function DataTableExpandedRow() {
const table = useDataTable({
columns,
data: accounts,
getRowCanExpand: () => true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 18 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts">
<table.Header />
<table.Body
renderExpandedRow={(row) => (
<Box
background="bg.muted"
paddingX="12"
paddingY="4"
width="100%"
>
<HStack gap="10">
<Stack gap="0">
<Text color="fg.muted" textStyle="xs">
Domain
</Text>
<Text textStyle="sm">{row.original.domain}</Text>
</Stack>
<Stack gap="0">
<Text color="fg.muted" textStyle="xs">
Owner
</Text>
<Text textStyle="sm">{row.original.owner}</Text>
</Stack>
<Stack gap="0">
<Text color="fg.muted" textStyle="xs">
Renewal
</Text>
<Text textStyle="sm">{row.original.renewal}</Text>
</Stack>
<Stack gap="0">
<Text color="fg.muted" textStyle="xs">
Annual value
</Text>
<Text textStyle="sm">
<FormatNumber
currency="USD"
maximumFractionDigits={0}
style="currency"
value={row.original.mrr * 12}
/>
</Text>
</Stack>
</HStack>
</Box>
)}
/>
</table.Table>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Cell Selection
Set enableCellSelection to enable spreadsheet-style cell selection. Cells can
be selected by dragging, or by holding Shift and Cmd to select ranges.
Northstar Labs northstarlabs.test | Enterprise | Active | $800 | Jan 18, 2026 | Maya Chen |
Kite & Harbor kiteharbor.test | Growth | Trial | $2,170 | Feb 18, 2026 | Noah Williams |
Arcwell Health arcwellhealth.test | Starter | Paused | $3,540 | Mar 18, 2026 | Priya Shah |
Fieldnote Studio fieldnotestudio.test | Enterprise | Active | $4,910 | Apr 18, 2026 | Jon Bell |
Copperline copperline.test | Growth | Trial | $6,280 | May 18, 2026 | Maya Chen |
Daybreak Energy daybreakenergy.test | Starter | Paused | $7,650 | Jun 18, 2026 | Noah Williams |
Orbit Commerce orbitcommerce.test | Enterprise | Active | $9,020 | Jul 18, 2026 | Priya Shah |
Tandem Security tandemsecurity.test | Growth | Trial | $10,390 | Aug 18, 2026 | Jon Bell |
Goodweather goodweather.test | Starter | Paused | $11,760 | Sep 18, 2026 | Maya Chen |
Lattice Grove latticegrove.test | Enterprise | Active | $13,130 | Oct 18, 2026 | Noah Williams |
0 cells selected
'use client'
import { Stack, Text } from '@chakra-ui/react'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
domain: string
plan: 'Enterprise' | 'Growth' | 'Starter'
status: 'Active' | 'Trial' | 'Paused'
mrr: number
renewal: string
owner: string
}
const companies = [
'Northstar Labs',
'Kite & Harbor',
'Arcwell Health',
'Fieldnote Studio',
'Copperline',
'Daybreak Energy',
'Orbit Commerce',
'Tandem Security',
'Goodweather',
'Lattice Grove',
]
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const statuses = ['Active', 'Trial', 'Paused'] as const
const owners = ['Maya Chen', 'Noah Williams', 'Priya Shah', 'Jon Bell']
const accounts: Account[] = companies.map((company, index) => ({
id: `acc_${index + 1}`,
company,
domain: `${company.toLowerCase().replace(/[^a-z]/g, '')}.test`,
plan: plans[index % plans.length]!,
status: statuses[index % statuses.length]!,
mrr: 800 + ((index * 1370) % 18000),
renewal: `2026-${String((index % 12) + 1).padStart(2, '0')}-18`,
owner: owners[index % owners.length]!,
}))
const statusPalette = (value: string) =>
value === 'Active' ? 'green' : value === 'Trial' ? 'blue' : 'orange'
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ row }) => (
<Stack gap="0" minWidth="0">
<Text fontWeight="medium" overflow="hidden" textOverflow="ellipsis">
{row.original.company}
</Text>
<Text
color="fg.muted"
overflow="hidden"
textOverflow="ellipsis"
textStyle="xs"
>
{row.original.domain}
</Text>
</Stack>
),
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 120,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: ({ cell }) => <cell.BadgeCell colorPalette={statusPalette} />,
size: 110,
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 110,
}),
columnHelper.accessor('renewal', {
header: 'Renewal',
cell: ({ cell }) => <cell.DateCell />,
size: 140,
}),
columnHelper.accessor('owner', {
header: 'Owner',
cell: ({ cell }) => <cell.TextCell />,
size: 150,
}),
])
/**
* Spreadsheet-style cell selection: click a cell, drag to extend the range,
* shift-click to extend from the anchor, cmd/ctrl-click to add or subtract
* ranges. Opt in with `enableCellSelection`.
*/
export function DataTableCellSelection() {
const table = useDataTable({
columns,
data: accounts,
enableCellSelection: true,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: 18 },
},
})
return (
<table.Provider>
<table.Root variant="outline">
<table.ScrollArea maxHeight="30rem">
<table.Table aria-label="Accounts" />
</table.ScrollArea>
<table.Pagination>
<table.Subscribe selector={(state) => state.cellSelection}>
{() => (
<Text textStyle="sm">
{table.getSelectedCellCount()} cells selected
</Text>
)}
</table.Subscribe>
</table.Pagination>
</table.Root>
</table.Provider>
)
}
Virtual Rows
Wrap table.Table in table.Virtualizer to only render the rows in view. This
lets you render tens of thousands of rows without paginating.
table.ScrollArea with a fixed height, and can't be
combined with renderExpandedRow.'use client'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
plan: 'Enterprise' | 'Growth' | 'Starter'
mrr: number
}
const plans = ['Enterprise', 'Growth', 'Starter'] as const
const accounts: Account[] = Array.from({ length: 5000 }, (_, index) => ({
id: `acc_${String(index + 1).padStart(5, '0')}`,
company: `Account ${index + 1}`,
plan: plans[index % plans.length]!,
mrr: 800 + ((index * 137) % 18000),
}))
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 220,
sortFn: 'text',
}),
columnHelper.accessor('plan', {
header: 'Plan',
cell: ({ cell }) => <cell.TextCell />,
size: 140,
sortFn: 'text',
}),
columnHelper.accessor('mrr', {
header: 'MRR',
cell: ({ cell }) => (
<cell.NumberCell
currency="USD"
maximumFractionDigits={0}
style="currency"
/>
),
meta: { isNumeric: true },
size: 140,
}),
])
/** 5,000 rows — only the visible window is rendered. */
export function DataTableVirtualRows() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
pagination: { pageIndex: 0, pageSize: accounts.length },
sorting: [{ id: 'company', desc: false }],
},
})
return (
<table.Provider>
<table.Root height="24rem" variant="outline">
<table.ScrollArea flex="1" minHeight="0">
<table.Virtualizer>
<table.Table aria-label="Virtualized accounts" />
</table.Virtualizer>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Virtual Grid
Pass the columns prop to table.Virtualizer to window the columns as well.
Pinned columns are always rendered.
'use client'
import {
createDataTableColumnHelper,
useDataTable,
} from '#components/ui/data-table'
interface Account {
id: string
company: string
[metric: `metric${number}`]: string | number
}
const METRIC_COUNT = 60
const accounts: Account[] = Array.from({ length: 5000 }, (_, index) => {
const account: Account = {
id: `acc_${String(index + 1).padStart(5, '0')}`,
company: `Account ${index + 1}`,
}
for (let metric = 0; metric < METRIC_COUNT; metric++) {
account[`metric${metric}`] = ((index + 1) * (metric + 3)) % 9973
}
return account
})
const columnHelper = createDataTableColumnHelper<Account>()
const columns = columnHelper.columns([
columnHelper.accessor('company', {
header: 'Account',
cell: ({ cell }) => <cell.TextCell />,
size: 200,
sortFn: 'text',
}),
...Array.from({ length: METRIC_COUNT }, (_, metric) =>
columnHelper.accessor(`metric${metric}`, {
id: `metric${metric}`,
header: `Metric ${metric + 1}`,
cell: ({ cell }) => <cell.TextCell />,
meta: { isNumeric: true },
size: 110,
}),
),
])
/**
* Rows and columns are both virtualized — 5,000 rows by 61 columns, with the
* first column pinned so it stays visible while scrolling sideways.
*/
export function DataTableVirtualGrid() {
const table = useDataTable({
columns,
data: accounts,
getRowId: (row) => row.id,
initialState: {
columnPinning: { start: ['company'], end: [] },
pagination: { pageIndex: 0, pageSize: accounts.length },
},
})
return (
<table.Provider>
<table.Root height="24rem" layout="fixed" variant="outline">
<table.ScrollArea flex="1" minHeight="0">
<table.Virtualizer columns>
<table.Table aria-label="Virtualized metrics grid" />
</table.Virtualizer>
</table.ScrollArea>
</table.Root>
</table.Provider>
)
}
Props
Root
| Prop | Default | Type |
|---|---|---|
variant | 'line' | 'line' | 'outline'The visual style of the table. |
size | 'md' | 'sm' | 'md' | 'lg'The size of the table. |
layout | 'grow' | 'grow' | 'fixed'`grow` stretches columns to fill the available width, `fixed` keeps columns at their configured sizes. Column virtualization always behaves as `fixed`. |
striped | false | booleanWhether to add zebra-stripes to the rows. |
stickyHeader | true | booleanKeeps the header visible while the body scrolls. |
Body
| Prop | Default | Type |
|---|---|---|
children | (row: Row<TData>, rowProps: DataTableRowRenderProps) => React.ReactNodeCustom row renderer. The body keeps owning the loop, empty state and virtualization, the callback only decides what each row looks like. Spread `rowProps` into `table.Row`. | |
onRowClick | (row: Row<TData>, event: React.MouseEvent | React.KeyboardEvent) => voidRows become clickable and keyboard-interactive when set. Applies to the default row rendering, custom `children` handle their own interaction. | |
renderExpandedRow | (row: Row<TData>) => React.ReactNodeRenders an extra full-width row below rows where `getIsExpanded()` is true. Not supported in combination with row virtualization. | |
emptyState | React.ReactNodeRendered when the row model is empty. |
Footer
| Prop | Default | Type |
|---|---|---|
sticky | true | booleanKeeps the footer visible while the body scrolls. |
Virtualizer
| Prop | Default | Type |
|---|---|---|
children * | React.ReactNodeThe `table.Table` part to virtualize. | |
rows | true | booleanWhether to virtualize the rows (vertical). |
columns | false | booleanWhether to virtualize the center (unpinned) columns (horizontal). Pinned columns always render. |
rowOptions | Partial<VirtualizerOptions<HTMLDivElement, HTMLTableRowElement>>Options passed to the row virtualizer, for example `overscan` or `estimateSize`. | |
columnOptions | Partial<VirtualizerOptions<HTMLDivElement, HTMLTableCellElement>>Options passed to the column virtualizer. |