Invoice Customer Status Amount INV-1042 Acme Corp Paid $2,500.00 INV-1043 Globex Pending $1,150.00 INV-1044 Initech Overdue $3,420.00 INV-1045 Umbrella Paid $980.00
import { Badge } from "@/components/ui/badge" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const invoices = [
{
id: "INV-1042" ,
customer: "Acme Corp" ,
status: "Paid" ,
amount: "$2,500.00" ,
},
{
id: "INV-1043" ,
customer: "Globex" ,
status: "Pending" ,
amount: "$1,150.00" ,
},
{
id: "INV-1044" ,
customer: "Initech" ,
status: "Overdue" ,
amount: "$3,420.00" ,
},
{ id: "INV-1045" , customer: "Umbrella" , status: "Paid" , amount: "$980.00" },
];
const tone = {
Paid: "success" ,
Pending: "warning" ,
Overdue: "danger" ,
} as const ;
export default function TableDemo () {
return (
< Table aria-label = "Invoices" className = "min-w-[480px]" >
< TableHeader >
< Column isRowHeader >Invoice</ Column >
< Column >Customer</ Column >
< Column >Status</ Column >
< Column className = "text-right" >Amount</ Column >
</ TableHeader >
< TableBody items = {invoices}>
{( invoice ) => (
< Row >
< Cell className = "font-medium" >{invoice.id}</ Cell >
< Cell >{invoice.customer}</ Cell >
< Cell >
< Badge
variant = "dot"
color = {tone[invoice.status as keyof typeof tone]}
>
{invoice.status}
</ Badge >
</ Cell >
< Cell className = "text-right tabular-nums" >{invoice.amount}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
);
}
CLI Manual
$ pnpm dlx shadcn@latest add @desyne/table
The CLI installs dependencies and any other components this one uses.
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
< Table aria-label = "Users" >
< TableHeader >
< Column isRowHeader >Name</ Column >
< Column >Role</ Column >
</ TableHeader >
< TableBody items = {users}>
{( user ) => (
< Row >
< Cell >{user.name}</ Cell >
< Cell >{user.role}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
Mark a row header
Set isRowHeader on the column that identifies each row (usually a name or
id). Screen readers read it when moving between rows, so users know which
row they're in. Every item in items also needs a unique id.
Table — comparing records across several attributes, with sorting, selection or bulk actions.
Grid List — a single column of rich rows, cards, or mobile-first lists.
List Box — picking values, not browsing data.
For simple, non-interactive tabular content in prose, a plain HTML <table> is fine.
< Table > { /* scroll container + role="grid" */ }
< TableHeader > { /* adds a select-all column when selecting */ }
< Column isRowHeader />
< Column allowsSorting />
</ TableHeader >
< TableBody >
< Row > { /* adds a checkbox cell when selecting */ }
< Cell />
< Cell />
</ Row >
</ TableBody >
</ Table >
Part Renders Notes Table<div> › <table role="grid">The div scrolls horizontally and draws the card frame (framed). Provides density, striped and bordered to every cell. TableHeader<thead>Muted background. Adds a checkbox column (select all in multiple mode) when selectionBehavior is toggle. Column<th role="columnheader">Small, muted, non-wrapping. Shows a sort arrow when allowsSorting. TableBody<tbody>Rows, or renderEmptyState when there are none. Row<tr role="row">Hover and selected backgrounds. Adds a checkbox cell when selection uses checkboxes. Cell<td role="gridcell">Non-wrapping, padded by density.
density sets cell padding (compact, default, comfortable). striped shades every other row, and bordered adds vertical grid lines.
Compact Default Comfortable
Host Region CPU api-prod-01 us-east-1 42% api-prod-02 us-east-1 38% worker-01 eu-central-1 71% worker-02 eu-central-1 66%
import { useState } from "react" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
import { ToggleButton } from "@/components/ui/toggle-button" ;
import { ToggleButtonGroup } from "@/components/ui/toggle-button-group" ;
const rows = [
{ id: 1 , host: "api-prod-01" , region: "us-east-1" , cpu: "42%" },
{ id: 2 , host: "api-prod-02" , region: "us-east-1" , cpu: "38%" },
{ id: 3 , host: "worker-01" , region: "eu-central-1" , cpu: "71%" },
{ id: 4 , host: "worker-02" , region: "eu-central-1" , cpu: "66%" },
];
export default function TableDensity () {
const [ density , setDensity ] = useState < "compact" | "default" | "comfortable" >(
"compact" ,
);
return (
< div className = "flex w-full flex-col gap-3" >
< ToggleButtonGroup
variant = "segmented"
size = "sm"
aria-label = "Density"
selectedKeys = {[density]}
onSelectionChange = {( keys ) => setDensity ([ ... keys][ 0 ] as typeof density)}
disallowEmptySelection
>
< ToggleButton id = "compact" >Compact</ ToggleButton >
< ToggleButton id = "default" >Default</ ToggleButton >
< ToggleButton id = "comfortable" >Comfortable</ ToggleButton >
</ ToggleButtonGroup >
< Table
aria-label = "Hosts"
density = {density}
striped
bordered
className = "min-w-[420px]"
>
< TableHeader >
< Column isRowHeader >Host</ Column >
< Column >Region</ Column >
< Column className = "text-right" >CPU</ Column >
</ TableHeader >
< TableBody items = {rows}>
{( r ) => (
< Row >
< Cell className = "font-mono text-xs" >{r.host}</ Cell >
< Cell >{r.region}</ Cell >
< Cell className = "text-right tabular-nums" >{r.cpu}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
framed={false} drops the table's own border and shadow so it can sit inside a card or panel.
Plan limits Limit Free Pro API requests 10k / mo 1M / mo Seats 3 Unlimited Storage 1 GB 100 GB Log retention 7 days 90 days
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const limits = [
{ id: "requests" , name: "API requests" , free: "10k / mo" , pro: "1M / mo" },
{ id: "seats" , name: "Seats" , free: "3" , pro: "Unlimited" },
{ id: "storage" , name: "Storage" , free: "1 GB" , pro: "100 GB" },
{ id: "retention" , name: "Log retention" , free: "7 days" , pro: "90 days" },
];
export default function TableFramed () {
return (
< div className = "w-full max-w-lg rounded-xl border bg-card p-4 shadow-xs" >
< h3 className = "mb-2 font-semibold text-sm" >Plan limits</ h3 >
< Table aria-label = "Plan limits" framed = { false } density = "compact" >
< TableHeader >
< Column isRowHeader >Limit</ Column >
< Column className = "text-right" >Free</ Column >
< Column className = "text-right" >Pro</ Column >
</ TableHeader >
< TableBody items = {limits}>
{( l ) => (
< Row >
< Cell >{l.name}</ Cell >
< Cell className = "text-right text-muted-foreground" >{l.free}</ Cell >
< Cell className = "text-right font-medium" >{l.pro}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
selectionMode="single" adds a checkbox column without a select-all box. Combine with disallowEmptySelection for a required choice.
Plan CPU Memory Price Starter 1 vCPU 1 GB $5 / mo Standard 2 vCPU 4 GB $24 / mo Performance 4 vCPU 8 GB $48 / mo Memory optimized 4 vCPU 32 GB $96 / mo
Selected: Standard
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const plans = [
{
id: "starter" ,
name: "Starter" ,
cpu: "1 vCPU" ,
memory: "1 GB" ,
price: "$5 / mo" ,
},
{
id: "standard" ,
name: "Standard" ,
cpu: "2 vCPU" ,
memory: "4 GB" ,
price: "$24 / mo" ,
},
{
id: "performance" ,
name: "Performance" ,
cpu: "4 vCPU" ,
memory: "8 GB" ,
price: "$48 / mo" ,
},
{
id: "memory" ,
name: "Memory optimized" ,
cpu: "4 vCPU" ,
memory: "32 GB" ,
price: "$96 / mo" ,
},
];
export default function TableSingleSelection () {
const [ selected , setSelected ] = useState < Selection >( new Set ([ "standard" ]));
const plan = plans. find (( p ) => selected !== "all" && selected. has (p.id));
return (
< div className = "flex w-full max-w-xl flex-col gap-2" >
< Table
aria-label = "Instance size"
selectionMode = "single"
disallowEmptySelection
selectedKeys = {selected}
onSelectionChange = {setSelected}
>
< TableHeader >
< Column isRowHeader >Plan</ Column >
< Column >CPU</ Column >
< Column >Memory</ Column >
< Column className = "text-right" >Price</ Column >
</ TableHeader >
< TableBody items = {plans}>
{( p ) => (
< Row >
< Cell className = "font-medium" >{p.name}</ Cell >
< Cell >{p.cpu}</ Cell >
< Cell >{p.memory}</ Cell >
< Cell className = "text-right tabular-nums" >{p.price}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
< p className = "text-muted-foreground text-xs" >
Selected: {plan?.name ?? "none" }
</ p >
</ div >
);
}
selectionMode="multiple" adds a select-all checkbox to the header. Sorting is controlled: give sortable columns an id and allowsSorting, then sort your data in onSortChange.
1 selected
Name Role IN Isabella Nguyen isabella@acme.com
Owner JL Jackson Lee jackson@acme.com
Member OM Olivia Martin olivia@acme.com
Admin WK William Kim will@acme.com
Member
import { useMemo, useState } from "react" ;
import type { Selection, SortDescriptor } from "react-aria-components" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Badge } from "@/components/ui/badge" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const users = [
{ id: 1 , name: "Olivia Martin" , email: "olivia@acme.com" , role: "Admin" },
{ id: 2 , name: "Jackson Lee" , email: "jackson@acme.com" , role: "Member" },
{ id: 3 , name: "Isabella Nguyen" , email: "isabella@acme.com" , role: "Owner" },
{ id: 4 , name: "William Kim" , email: "will@acme.com" , role: "Member" },
];
type User = ( typeof users)[ number ];
export default function TableSelectionSorting () {
const [ sort , setSort ] = useState < SortDescriptor >({
column: "name" ,
direction: "ascending" ,
});
const [ selected , setSelected ] = useState < Selection >( new Set ([ 2 ]));
const items = useMemo (() => {
const key = sort.column as keyof User ;
return [ ... users]. sort (( a , b ) => {
const cmp = String (a[key]). localeCompare ( String (b[key]));
return sort.direction === "descending" ? - cmp : cmp;
});
}, [sort]);
const count = selected === "all" ? users. length : selected.size;
return (
< div className = "flex w-full flex-col gap-2" >
< p className = "text-muted-foreground text-xs" >{count} selected</ p >
< Table
aria-label = "Members"
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
sortDescriptor = {sort}
onSortChange = {setSort}
className = "min-w-[420px]"
>
< TableHeader >
< Column id = "name" isRowHeader allowsSorting >
Name
</ Column >
< Column id = "role" allowsSorting >
Role
</ Column >
</ TableHeader >
< TableBody items = {items}>
{( user ) => (
< Row >
< Cell >
< div className = "flex items-center gap-2.5" >
< Avatar
size = "sm"
colorful
alt = {user.name}
fallback = {user.name
. split ( " " )
. map (( n ) => n[ 0 ])
. join ( "" )}
/>
< div className = "flex flex-col" >
< span className = "font-medium" >{user.name}</ span >
< span className = "text-muted-foreground text-xs" >
{user.email}
</ span >
</ div >
</ div >
</ Cell >
< Cell >
< Badge color = {user.role === "Owner" ? "primary" : "neutral" }>
{user.role}
</ Badge >
</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
Clicking a sortable header (or pressing Enter on it) toggles ascending and descending. The table only reports the new SortDescriptor; compare numbers numerically and strings with localeCompare (or Intl.Collator) yourself.
Country Capital Population (M) Area (km²) Brazil Brasília 216.4 8,515,767 Japan Tokyo 124.5 377,975 Germany Berlin 84.5 357,588 Kenya Nairobi 55.1 580,367 Canada Ottawa 40.1 9,984,670 Nepal Kathmandu 30.9 147,516
import { useMemo, useState } from "react" ;
import type { SortDescriptor } from "react-aria-components" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const countries = [
{
id: "de" ,
name: "Germany" ,
capital: "Berlin" ,
population: 84.5 ,
area: 357588 ,
},
{
id: "jp" ,
name: "Japan" ,
capital: "Tokyo" ,
population: 124.5 ,
area: 377975 ,
},
{
id: "br" ,
name: "Brazil" ,
capital: "Brasília" ,
population: 216.4 ,
area: 8515767 ,
},
{
id: "np" ,
name: "Nepal" ,
capital: "Kathmandu" ,
population: 30.9 ,
area: 147516 ,
},
{
id: "ca" ,
name: "Canada" ,
capital: "Ottawa" ,
population: 40.1 ,
area: 9984670 ,
},
{
id: "ke" ,
name: "Kenya" ,
capital: "Nairobi" ,
population: 55.1 ,
area: 580367 ,
},
];
type Country = ( typeof countries)[ number ];
export default function TableSorting () {
const [ sort , setSort ] = useState < SortDescriptor >({
column: "population" ,
direction: "descending" ,
});
const rows = useMemo (() => {
const key = sort.column as keyof Country ;
return [ ... countries]. sort (( a , b ) => {
const x = a[key];
const y = b[key];
const cmp =
typeof x === "number" && typeof y === "number"
? x - y
: String (x). localeCompare ( String (y));
return sort.direction === "descending" ? - cmp : cmp;
});
}, [sort]);
return (
< div className = "w-full max-w-xl" >
< Table
aria-label = "Countries"
sortDescriptor = {sort}
onSortChange = {setSort}
>
< TableHeader >
< Column id = "name" isRowHeader allowsSorting >
Country
</ Column >
< Column id = "capital" allowsSorting >
Capital
</ Column >
< Column id = "population" allowsSorting className = "text-right" >
Population (M)
</ Column >
< Column id = "area" allowsSorting className = "text-right" >
Area (km²)
</ Column >
</ TableHeader >
< TableBody items = {rows}>
{( c ) => (
< Row >
< Cell className = "font-medium" >{c.name}</ Cell >
< Cell >{c.capital}</ Cell >
< Cell className = "text-right tabular-nums" >
{c.population. toFixed ( 1 )}
</ Cell >
< Cell className = "text-right tabular-nums" >
{c.area. toLocaleString ()}
</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
selectionBehavior="replace" removes the checkbox column. A click selects one row, ⌘ /Ctrl and Shift add more, and arrow keys move the selection like a desktop mail client.
From Subject Date Stripe Your October payout is on its way Oct 2 Linear Weekly digest: 14 issues closed Oct 1 Vercel Deployment succeeded for web-app Sep 30 GitHub [acme/api] PR #482 approved Sep 30 Figma Ana shared “Checkout v3” with you Sep 29
Click selects a row. ⌘/Ctrl or Shift to select more.
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const emails = [
{
id: 1 ,
from: "Stripe" ,
subject: "Your October payout is on its way" ,
date: "Oct 2" ,
},
{
id: 2 ,
from: "Linear" ,
subject: "Weekly digest: 14 issues closed" ,
date: "Oct 1" ,
},
{
id: 3 ,
from: "Vercel" ,
subject: "Deployment succeeded for web-app" ,
date: "Sep 30" ,
},
{
id: 4 ,
from: "GitHub" ,
subject: "[acme/api] PR #482 approved" ,
date: "Sep 30" ,
},
{
id: 5 ,
from: "Figma" ,
subject: "Ana shared “Checkout v3” with you" ,
date: "Sep 29" ,
},
];
export default function TableSelectionBehavior () {
return (
< div className = "flex w-full max-w-xl flex-col gap-2" >
< Table
aria-label = "Inbox"
selectionMode = "multiple"
selectionBehavior = "replace"
density = "compact"
defaultSelectedKeys = {[ 2 ]}
>
< TableHeader >
< Column isRowHeader >From</ Column >
< Column >Subject</ Column >
< Column className = "text-right" >Date</ Column >
</ TableHeader >
< TableBody items = {emails}>
{( m ) => (
< Row >
< Cell className = "font-medium" >{m.from}</ Cell >
< Cell className = "max-w-64 truncate" >{m.subject}</ Cell >
< Cell className = "text-right text-muted-foreground" >{m.date}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
< p className = "text-muted-foreground text-xs" >
Click selects a row. ⌘/Ctrl or Shift to select more.
</ p >
</ div >
);
}
disabledKeys prevents selecting (and by default focusing) rows. Style them with data-disabled: on Row. disabledBehavior="selection" keeps them focusable.
Name Key Status Production sk_live_4f… Active Staging sk_test_91… Active Legacy integration sk_live_0a… Revoked CI pipeline sk_test_7c… Active
import { Badge } from "@/components/ui/badge" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const keys = [
{ id: "k1" , name: "Production" , prefix: "sk_live_4f…" , status: "Active" },
{ id: "k2" , name: "Staging" , prefix: "sk_test_91…" , status: "Active" },
{
id: "k3" ,
name: "Legacy integration" ,
prefix: "sk_live_0a…" ,
status: "Revoked" ,
},
{ id: "k4" , name: "CI pipeline" , prefix: "sk_test_7c…" , status: "Active" },
];
export default function TableDisabled () {
return (
< div className = "w-full max-w-xl" >
< Table
aria-label = "API keys"
selectionMode = "multiple"
disabledKeys = {keys
. filter (( k ) => k.status === "Revoked" )
. map (( k ) => k.id)}
>
< TableHeader >
< Column isRowHeader >Name</ Column >
< Column >Key</ Column >
< Column >Status</ Column >
</ TableHeader >
< TableBody items = {keys}>
{( k ) => (
< Row className = "data-disabled:text-muted-foreground" >
< Cell className = "font-medium" >{k.name}</ Cell >
< Cell className = "font-mono text-xs" >{k.prefix}</ Cell >
< Cell >
< Badge
variant = "dot"
color = {k.status === "Active" ? "success" : "neutral" }
>
{k.status}
</ Badge >
</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
onRowAction makes rows actionable: clicking a row or pressing Enter calls it with the row's key. With checkbox selection as well, clicking a row performs the action while nothing is selected, and toggles selection once rows are selected; use selectionBehavior="replace" to select on click and act on double click or Enter .
Commit Branch Environment Age Fix checkout tax rounding main Production 4m Add SAML metadata endpoint feat/sso Preview 38m Bump dependencies main Production 2h Keep focus in popover fix/date-picker Preview 5h
Click a row or press Enter to open a deployment.
import { useState } from "react" ;
import type { Key } from "react-aria-components" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const deployments = [
{
id: "dpl_8f2a" ,
branch: "main" ,
commit: "Fix checkout tax rounding" ,
env: "Production" ,
age: "4m" ,
},
{
id: "dpl_71c9" ,
branch: "feat/sso" ,
commit: "Add SAML metadata endpoint" ,
env: "Preview" ,
age: "38m" ,
},
{
id: "dpl_5e10" ,
branch: "main" ,
commit: "Bump dependencies" ,
env: "Production" ,
age: "2h" ,
},
{
id: "dpl_3b44" ,
branch: "fix/date-picker" ,
commit: "Keep focus in popover" ,
env: "Preview" ,
age: "5h" ,
},
];
export default function TableRowActions () {
const [ opened , setOpened ] = useState < Key | null >( null );
const current = deployments. find (( d ) => d.id === opened);
return (
< div className = "flex w-full max-w-2xl flex-col gap-2" >
< Table aria-label = "Deployments" onRowAction = {setOpened}>
< TableHeader >
< Column isRowHeader >Commit</ Column >
< Column >Branch</ Column >
< Column >Environment</ Column >
< Column className = "text-right" >Age</ Column >
</ TableHeader >
< TableBody items = {deployments}>
{( d ) => (
< Row className = "cursor-pointer" >
< Cell className = "font-medium" >{d.commit}</ Cell >
< Cell className = "font-mono text-xs" >{d.branch}</ Cell >
< Cell >{d.env}</ Cell >
< Cell className = "text-right text-muted-foreground" >{d.age}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
< p className = "text-muted-foreground text-xs" >
{current
? `Opened ${ current . id } (${ current . commit })`
: "Click a row or press Enter to open a deployment." }
</ p >
</ div >
);
}
Give a Row an href and the whole row navigates. Works with client-side routers through React Aria's RouterProvider.
Component Category Parts Button Actions 1 Select Forms 4 Dialog Overlays 9 Table Collections 6
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const components = [
{ id: "button" , name: "Button" , category: "Actions" , parts: 1 },
{ id: "select" , name: "Select" , category: "Forms" , parts: 4 },
{ id: "dialog" , name: "Dialog" , category: "Overlays" , parts: 9 },
{ id: "table" , name: "Table" , category: "Collections" , parts: 6 },
];
export default function TableLinks () {
return (
< div className = "w-full max-w-xl" >
< Table aria-label = "Components" >
< TableHeader >
< Column isRowHeader >Component</ Column >
< Column >Category</ Column >
< Column className = "text-right" >Parts</ Column >
</ TableHeader >
< TableBody items = {components}>
{( c ) => (
< Row href = { `/docs/components/${ c . id }` } className = "cursor-pointer" >
< Cell className = "font-medium text-brand" >{c.name}</ Cell >
< Cell >{c.category}</ Cell >
< Cell className = "text-right tabular-nums" >{c.parts}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
Pass columns to TableHeader and each Row, and render both with functions. Here a multi-select Menu toggles column visibility; dependencies on TableBody re-renders rows when the columns change.
ColumnsCustomer Plan Seats MRR Northwind Enterprise 240 $18,400 Fabrikam Business 56 $3,920 Tailspin Pro 12 $480 Contoso Business 88 $6,160
import { Columns3Icon } from "lucide-react" ;
import { useState } from "react" ;
import type { Selection } from "react-aria-components" ;
import { Button } from "@/components/ui/button" ;
import { MenuContent, MenuItem, MenuTrigger } from "@/components/ui/menu" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const allColumns = [
{ id: "name" , name: "Customer" , isRowHeader: true },
{ id: "email" , name: "Email" },
{ id: "plan" , name: "Plan" },
{ id: "seats" , name: "Seats" },
{ id: "mrr" , name: "MRR" },
{ id: "country" , name: "Country" },
] as const ;
type ColumnId = ( typeof allColumns)[ number ][ "id" ];
const customers : ({ id : number } & Record < ColumnId , string >)[] = [
{
id: 1 ,
name: "Northwind" ,
email: "ops@northwind.io" ,
plan: "Enterprise" ,
seats: "240" ,
mrr: "$18,400" ,
country: "US" ,
},
{
id: 2 ,
name: "Fabrikam" ,
email: "it@fabrikam.com" ,
plan: "Business" ,
seats: "56" ,
mrr: "$3,920" ,
country: "DE" ,
},
{
id: 3 ,
name: "Tailspin" ,
email: "hello@tailspin.dev" ,
plan: "Pro" ,
seats: "12" ,
mrr: "$480" ,
country: "CA" ,
},
{
id: 4 ,
name: "Contoso" ,
email: "billing@contoso.co" ,
plan: "Business" ,
seats: "88" ,
mrr: "$6,160" ,
country: "UK" ,
},
];
export default function TableDynamicColumns () {
const [ visible , setVisible ] = useState < Selection >(
new Set ([ "name" , "plan" , "seats" , "mrr" ]),
);
const columns = allColumns. filter (
( c ) => c.id === "name" || visible === "all" || visible. has (c.id),
);
return (
< div className = "flex w-full max-w-2xl flex-col items-end gap-2" >
< MenuTrigger >
< Button size = "sm" variant = "outline" >
< Columns3Icon /> Columns
</ Button >
< MenuContent
selectionMode = "multiple"
selectedKeys = {visible}
onSelectionChange = {setVisible}
disabledKeys = {[ "name" ]}
items = {allColumns}
placement = "bottom end"
>
{( c ) => < MenuItem >{c.name}</ MenuItem >}
</ MenuContent >
</ MenuTrigger >
< Table aria-label = "Customers" >
< TableHeader columns = {columns}>
{( c ) => (
< Column isRowHeader = { "isRowHeader" in c && c.isRowHeader}>
{c.name}
</ Column >
)}
</ TableHeader >
< TableBody items = {customers} dependencies = {[columns]}>
{( item ) => (
< Row columns = {columns}>{( c ) => < Cell >{item[c.id]}</ Cell >}</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
Cells take any content: avatars , badges , progress bars. Right-align numbers with text-right tabular-nums on both the column and its cells.
Project Owner Status Progress Due Checkout v3 AS Ana SouzaOn track 72% Oct 18 SSO for enterprise KW Kenji WatanabeAt risk 41% Oct 31 Usage-based billing MP Maya PatelBlocked 18% Nov 14 Docs search LF Leo FischerDone 100% Sep 26
import { Avatar } from "@/components/ui/avatar" ;
import { Badge } from "@/components/ui/badge" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const projects = [
{
id: 1 ,
name: "Checkout v3" ,
owner: "Ana Souza" ,
status: "On track" ,
progress: 72 ,
due: "Oct 18" ,
},
{
id: 2 ,
name: "SSO for enterprise" ,
owner: "Kenji Watanabe" ,
status: "At risk" ,
progress: 41 ,
due: "Oct 31" ,
},
{
id: 3 ,
name: "Usage-based billing" ,
owner: "Maya Patel" ,
status: "Blocked" ,
progress: 18 ,
due: "Nov 14" ,
},
{
id: 4 ,
name: "Docs search" ,
owner: "Leo Fischer" ,
status: "Done" ,
progress: 100 ,
due: "Sep 26" ,
},
];
const tone = {
"On track" : "success" ,
"At risk" : "warning" ,
Blocked: "danger" ,
Done: "neutral" ,
} as const ;
export default function TableRichCells () {
return (
< div className = "w-full max-w-2xl" >
< Table aria-label = "Projects" >
< TableHeader >
< Column isRowHeader >Project</ Column >
< Column >Owner</ Column >
< Column >Status</ Column >
< Column className = "w-40" >Progress</ Column >
< Column className = "text-right" >Due</ Column >
</ TableHeader >
< TableBody items = {projects}>
{( p ) => (
< Row >
< Cell className = "font-medium" >{p.name}</ Cell >
< Cell >
< span className = "flex items-center gap-2" >
< Avatar
size = "xs"
colorful
alt = {p.owner}
fallback = {p.owner
. split ( " " )
. map (( n ) => n[ 0 ])
. join ( "" )}
/>
{p.owner}
</ span >
</ Cell >
< Cell >
< Badge
variant = "soft"
color = {tone[p.status as keyof typeof tone]}
>
{p.status}
</ Badge >
</ Cell >
< Cell >
< span className = "flex items-center gap-2" >
< span className = "h-1.5 flex-1 overflow-hidden rounded-full bg-muted" >
< span
className = "block h-full rounded-full bg-brand"
style = {{ width: `${ p . progress }%` }}
/>
</ span >
< span className = "w-8 text-right text-muted-foreground text-xs tabular-nums" >
{p.progress}%
</ span >
</ span >
</ Cell >
< Cell className = "text-right text-muted-foreground" >{p.due}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
renderEmptyState on TableBody fills the body when there are no rows.
Alert Severity Time No alerts match your filters.
import { SearchXIcon } from "lucide-react" ;
import { Column, Table, TableBody, TableHeader } from "@/components/ui/table" ;
export default function TableEmpty () {
return (
< Table aria-label = "Alerts" className = "min-w-[420px]" >
< TableHeader >
< Column isRowHeader >Alert</ Column >
< Column >Severity</ Column >
< Column >Time</ Column >
</ TableHeader >
< TableBody
renderEmptyState = {() => (
< div className = "flex flex-col items-center gap-2 py-4" >
< SearchXIcon className = "size-6 text-muted-foreground/60" />
< span >No alerts match your filters.</ span >
</ div >
)}
>
{[]}
</ TableBody >
</ Table >
);
}
Pass dragAndDropHooks from useDragAndDrop, add a column for the handle, and render a React Aria Button with slot="drag" in its cell. Rows can then be reordered by mouse, touch or keyboard.
Reorder Step Channel Notify 1 Email Immediately 2 Slack #on-call After 5 min 3 SMS After 10 min 4 Phone call After 15 min
import { GripVerticalIcon } from "lucide-react" ;
import { Button, useDragAndDrop, useListData } from "react-aria-components" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
export default function TableReorder () {
const list = useListData ({
initialItems: [
{ id: "email" , channel: "Email" , delay: "Immediately" },
{ id: "slack" , channel: "Slack #on-call" , delay: "After 5 min" },
{ id: "sms" , channel: "SMS" , delay: "After 10 min" },
{ id: "phone" , channel: "Phone call" , delay: "After 15 min" },
],
});
const { dragAndDropHooks } = useDragAndDrop ({
getItems : ( keys ) =>
[ ... keys]. map (( key ) => ({
"text/plain" : list. getItem (key)?.channel ?? "" ,
})),
onReorder ( e ) {
if (e.target.dropPosition === "before" ) {
list. moveBefore (e.target.key, e.keys);
} else if (e.target.dropPosition === "after" ) {
list. moveAfter (e.target.key, e.keys);
}
},
});
return (
< div className = "w-full max-w-lg" >
< Table aria-label = "Escalation policy" dragAndDropHooks = {dragAndDropHooks}>
< TableHeader >
< Column className = "w-8" >
< span className = "sr-only" >Reorder</ span >
</ Column >
< Column className = "w-10" >Step</ Column >
< Column isRowHeader >Channel</ Column >
< Column >Notify</ Column >
</ TableHeader >
< TableBody items = {list.items} dependencies = {[list.items]}>
{( item ) => (
< Row className = "data-dragging:opacity-50 data-drop-target:bg-accent" >
< Cell >
< Button
slot = "drag"
className = "flex cursor-grab items-center text-muted-foreground outline-none data-focus-visible:ring-2 data-focus-visible:ring-ring/30"
>
< GripVerticalIcon aria-hidden className = "size-4" />
</ Button >
</ Cell >
< Cell className = "text-muted-foreground tabular-nums" >
{list.items. indexOf (item) + 1 }
</ Cell >
< Cell className = "font-medium" >{item.channel}</ Cell >
< Cell >{item.delay}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
divided={false} removes the lines between rows. Pair it with striped (or nothing at all) for light, list-like tables such as plan comparisons.
Plan Seats Storage Per seat / month Starter 3 10 GB $0 Team 25 250 GB $12 Business 100 2 TB $24 Enterprise Unlimited Custom Talk to us
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const plans = [
{ id: "starter" , name: "Starter" , seats: "3" , storage: "10 GB" , price: "$0" },
{ id: "team" , name: "Team" , seats: "25" , storage: "250 GB" , price: "$12" },
{
id: "business" ,
name: "Business" ,
seats: "100" ,
storage: "2 TB" ,
price: "$24" ,
},
{
id: "enterprise" ,
name: "Enterprise" ,
seats: "Unlimited" ,
storage: "Custom" ,
price: "Talk to us" ,
},
];
export default function TableGridless () {
return (
< Table aria-label = "Plans" divided = { false } framed = { false } striped >
< TableHeader >
< Column isRowHeader >Plan</ Column >
< Column >Seats</ Column >
< Column >Storage</ Column >
< Column className = "text-right" >Per seat / month</ Column >
</ TableHeader >
< TableBody items = {plans}>
{( p ) => (
< Row >
< Cell className = "font-medium" >{p.name}</ Cell >
< Cell className = "tabular-nums" >{p.seats}</ Cell >
< Cell className = "tabular-nums" >{p.storage}</ Cell >
< Cell className = "text-right tabular-nums" >{p.price}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
);
}
bleed drops the frame and lets the table run into its parent's horizontal padding (-mx-4, sm:-mx-6), so row hovers and dividers reach the card's edges while the first and last columns stay aligned with the text around them. Use it inside cards and sheets that use px-4 sm:px-6.
Members The table runs edge to edge; its first and last columns line up with this text.
Name Role Last active M Maya Chen maya@fernhill.studio Owner 2 min ago T Tomás Rivera tomas@fernhill.studio Admin 1 h ago H Hana Kobayashi hana@fernhill.studio Editor Yesterday A Arjun Mehta arjun@fernhill.studio Viewer Sep 28
import { Avatar } from "@/components/ui/avatar" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const people = [
{
id: "1" ,
name: "Maya Chen" ,
email: "maya@fernhill.studio" ,
role: "Owner" ,
last: "2 min ago" ,
},
{
id: "2" ,
name: "Tomás Rivera" ,
email: "tomas@fernhill.studio" ,
role: "Admin" ,
last: "1 h ago" ,
},
{
id: "3" ,
name: "Hana Kobayashi" ,
email: "hana@fernhill.studio" ,
role: "Editor" ,
last: "Yesterday" ,
},
{
id: "4" ,
name: "Arjun Mehta" ,
email: "arjun@fernhill.studio" ,
role: "Viewer" ,
last: "Sep 28" ,
},
];
export default function TableBleed () {
return (
< section className = "w-full rounded-xl border bg-card px-4 py-5 sm:px-6" >
< h3 className = "font-semibold" >Members</ h3 >
< p className = "mt-1 text-muted-foreground text-sm" >
The table runs edge to edge; its first and last columns line up with
this text.
</ p >
< div className = "mt-4" >
< Table aria-label = "Workspace members" bleed >
< TableHeader >
< Column isRowHeader >Name</ Column >
< Column >Role</ Column >
< Column className = "text-right" >Last active</ Column >
</ TableHeader >
< TableBody items = {people}>
{( p ) => (
< Row >
< Cell >
< span className = "flex items-center gap-2.5" >
< Avatar
size = "sm"
alt = {p.name}
fallback = {p.name. slice ( 0 , 1 )}
/>
< span className = "flex flex-col" >
< span className = "font-medium" >{p.name}</ span >
< span className = "text-muted-foreground text-xs" >
{p.email}
</ span >
</ span >
</ span >
</ Cell >
< Cell className = "text-muted-foreground" >{p.role}</ Cell >
< Cell className = "text-right text-muted-foreground" >
{p.last}
</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
</ section >
);
}
Keep a set of expanded ids, put a TableExpandButton in the first cell and render the detail as an extra Row right after the parent, with one Cell that spans every column (colSpan). The button sets aria-expanded, and the detail row stays in the grid, so keyboard users reach it with the arrow keys.
Order Customer Status Total ORD-4821 Fernhill Studio Shipped $1,284.00 DSK-OAK-140 Oak desk, 140 cm 1 × $940.00 $940.00 LMP-BRS-02 Brass task lamp 2 × $172.00 $344.00 ORD-4820 Halcyon Health Processing $2,310.00 ORD-4819 Copperleaf Foods Delivered $486.00
import { Fragment, useState } from "react" ;
import { Badge } from "@/components/ui/badge" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableExpandButton,
TableHeader,
} from "@/components/ui/table" ;
const orders = [
{
id: "ORD-4821" ,
customer: "Fernhill Studio" ,
status: "Shipped" ,
total: 1284 ,
items: [
{ sku: "DSK-OAK-140" , name: "Oak desk, 140 cm" , qty: 1 , price: 940 },
{ sku: "LMP-BRS-02" , name: "Brass task lamp" , qty: 2 , price: 172 },
],
},
{
id: "ORD-4820" ,
customer: "Halcyon Health" ,
status: "Processing" ,
total: 2310 ,
items: [
{ sku: "CHR-ERG-11" , name: "Ergonomic chair" , qty: 3 , price: 690 },
{ sku: "MAT-FLT-90" , name: "Floor mat, 90 cm" , qty: 3 , price: 80 },
],
},
{
id: "ORD-4819" ,
customer: "Copperleaf Foods" ,
status: "Delivered" ,
total: 486 ,
items: [
{ sku: "SHF-WAL-80" , name: "Wall shelf, 80 cm" , qty: 3 , price: 162 },
],
},
];
const tone = {
Shipped: "info" ,
Processing: "warning" ,
Delivered: "success" ,
} as const ;
const money = ( n : number ) =>
n. toLocaleString ( "en-US" , { style: "currency" , currency: "USD" });
export default function TableExpandable () {
const [ open , setOpen ] = useState < Set < string >>( new Set ([ "ORD-4821" ]));
const toggle = ( id : string ) =>
setOpen (( s ) => {
const next = new Set (s);
next. has (id) ? next. delete (id) : next. add (id);
return next;
});
return (
< Table aria-label = "Orders" >
< TableHeader >
< Column width = { 44 } aria-label = "Expand" />
< Column isRowHeader >Order</ Column >
< Column >Customer</ Column >
< Column >Status</ Column >
< Column className = "text-right" >Total</ Column >
</ TableHeader >
< TableBody >
{orders. map (( o ) => (
< Fragment key = {o.id}>
< Row id = {o.id}>
< Cell >
< TableExpandButton
isExpanded = {open. has (o.id)}
onPress = {() => toggle (o.id)}
aria-label = { `${ open . has ( o . id ) ? "Hide" : "Show"} items in ${ o . id }` }
/>
</ Cell >
< Cell className = "font-medium font-mono text-xs" >{o.id}</ Cell >
< Cell >{o.customer}</ Cell >
< Cell >
< Badge
variant = "soft"
color = {tone[o.status as keyof typeof tone]}
>
{o.status}
</ Badge >
</ Cell >
< Cell className = "text-right tabular-nums" >{ money (o.total)}</ Cell >
</ Row >
{open. has (o.id) && (
< Row
id = { `${ o . id }-items` }
className = "bg-muted/30 data-hovered:bg-muted/30"
>
< Cell colSpan = { 5 } className = "py-0" >
< ul className = "divide-y py-1 pl-11" >
{o.items. map (( it ) => (
< li
key = {it.sku}
className = "flex items-center gap-4 py-2 text-sm"
>
< span className = "w-28 font-mono text-muted-foreground text-xs" >
{it.sku}
</ span >
< span className = "flex-1" >{it.name}</ span >
< span className = "text-muted-foreground tabular-nums" >
{it.qty} × { money (it.price)}
</ span >
< span className = "w-24 text-right tabular-nums" >
{ money (it.qty * it.price)}
</ span >
</ li >
))}
</ ul >
</ Cell >
</ Row >
)}
</ Fragment >
))}
</ TableBody >
</ Table >
);
}
Give the table a fixed-height scroll container with containerClassName (the header stays sticky) and add TableLoadMore as the last child of TableBody. It calls onLoadMore when it scrolls into view and shows a spinner row while isLoading is true. Remove it, or stop calling your loader, when there's nothing left to fetch.
Time (UTC) Service Level Message 14:00:00 api info Request completed 13:59:23 web warn Job enqueued 13:58:46 worker info Token refreshed 13:58:09 billing error Slow query (412 ms) 13:57:32 search info Retrying webhook delivery 13:56:55 auth info Cache miss, fetched from origin 13:56:18 api warn Request completed 13:55:41 web info Job enqueued 13:55:04 worker error Token refreshed 13:54:27 billing info Slow query (412 ms) 13:53:50 search info Retrying webhook delivery 13:53:13 auth warn Cache miss, fetched from origin 13:52:36 api info Request completed 13:51:59 web error Job enqueued 13:51:22 worker info Token refreshed 13:50:45 billing info Slow query (412 ms) 13:50:08 search warn Retrying webhook delivery 13:49:31 auth info Cache miss, fetched from origin 13:48:54 api error Request completed 13:48:17 web info Job enqueued
20 of 120 events
import { useEffect, useRef, useState } from "react" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
TableLoadMore,
} from "@/components/ui/table" ;
const services = [ "api" , "web" , "worker" , "billing" , "search" , "auth" ];
const levels = [ "info" , "info" , "info" , "warn" , "error" ] as const ;
const messages = [
"Request completed" ,
"Cache miss, fetched from origin" ,
"Retrying webhook delivery" ,
"Slow query (412 ms)" ,
"Token refreshed" ,
"Job enqueued" ,
];
function makeEvents ( start : number , count : number ) {
return Array. from ({ length: count }, ( _ , k ) => {
const i = start + k;
const t = new Date (Date. UTC ( 2026 , 9 , 5 , 14 , 0 , 0 ) - i * 37_000 );
return {
id: `evt_${ ( 9000 - i ). toString ( 36 ) }${ i }` ,
time: t. toISOString (). slice ( 11 , 19 ),
service: services[(i * 7 ) % services. length ],
level: levels[(i * 3 ) % levels. length ],
message: messages[(i * 5 ) % messages. length ],
};
});
}
const TOTAL = 120 ;
export default function TableInfinite () {
const [ rows , setRows ] = useState (() => makeEvents ( 0 , 20 ));
const [ loading , setLoading ] = useState ( false );
const timer = useRef < ReturnType < typeof setTimeout>>( undefined );
useEffect (() => () => clearTimeout (timer.current), []);
const loadMore = () => {
if (loading || rows. length >= TOTAL ) return ;
setLoading ( true );
// Simulates a paginated API call.
timer.current = setTimeout (() => {
setRows (( r ) => [ ... r, ... makeEvents (r. length , 20 )]);
setLoading ( false );
}, 700 );
};
return (
< div className = "flex w-full flex-col gap-2" >
< Table
aria-label = "Event log"
density = "compact"
containerClassName = "max-h-80"
>
< TableHeader >
< Column isRowHeader >Time (UTC)</ Column >
< Column >Service</ Column >
< Column >Level</ Column >
< Column >Message</ Column >
</ TableHeader >
< TableBody >
{rows. map (( e ) => (
< Row key = {e.id} id = {e.id}>
< Cell className = "font-mono text-xs tabular-nums" >{e.time}</ Cell >
< Cell className = "font-mono text-xs" >{e.service}</ Cell >
< Cell >
< span
className = {
e.level === "error"
? "text-destructive"
: e.level === "warn"
? "text-warning"
: "text-muted-foreground"
}
>
{e.level}
</ span >
</ Cell >
< Cell >{e.message}</ Cell >
</ Row >
))}
{rows. length < TOTAL && (
< TableLoadMore isLoading = {loading} onLoadMore = {loadMore} />
)}
</ TableBody >
</ Table >
< p className = "text-muted-foreground text-xs" aria-live = "polite" >
{rows. length } of { TOTAL } events
{rows. length >= TOTAL && " · end of log" }
</ p >
</ div >
);
}
Set allowsResizing on Table to let people drag column edges. Each Column gets a resize handle; give columns a defaultWidth (pixels, % or fr) and a minWidth/maxWidth to shape the starting layout, and opt one out with isResizable={false}. Keyboard users focus a header, press Enter to enter resize mode and use the arrow keys. onResizeEnd receives every column's width, which is the place to persist a user's layout. Cells truncate with an ellipsis while the table is resizable.
Drag a column edge, or focus a header and press Enter to resize with the arrow keys.
import { useMemo, useState } from "react" ;
import type { SortDescriptor } from "react-aria-components" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const files = [
{
id: "1" ,
name: "Q3 board deck — final review.pdf" ,
owner: "Maya Chen" ,
type: "PDF" ,
size: 4.2 ,
updated: "Oct 2, 2026" ,
},
{
id: "2" ,
name: "Brand guidelines 2027.fig" ,
owner: "Tomás Rivera" ,
type: "Figma" ,
size: 18.6 ,
updated: "Sep 30, 2026" ,
},
{
id: "3" ,
name: "Customer interviews — synthesis notes.docx" ,
owner: "Hana Kobayashi" ,
type: "Doc" ,
size: 0.8 ,
updated: "Sep 28, 2026" ,
},
{
id: "4" ,
name: "Pricing model v4.xlsx" ,
owner: "Arjun Mehta" ,
type: "Sheet" ,
size: 1.3 ,
updated: "Sep 27, 2026" ,
},
{
id: "5" ,
name: "Launch video — 30s cut.mp4" ,
owner: "Lena Fischer" ,
type: "Video" ,
size: 212.4 ,
updated: "Sep 21, 2026" ,
},
];
export default function TableResizable () {
const [ sort , setSort ] = useState < SortDescriptor >({
column: "updated" ,
direction: "descending" ,
});
const [ widths , setWidths ] = useState < string >( "" );
const rows = useMemo (() => {
const key = sort.column as keyof ( typeof files)[ number ];
const sorted = [ ... files]. sort (( a , b ) =>
key === "size"
? a.size - b.size
: key === "updated"
? Date. parse (a.updated) - Date. parse (b.updated)
: String (a[key]). localeCompare ( String (b[key])),
);
return sort.direction === "descending" ? sorted. reverse () : sorted;
}, [sort]);
return (
< div className = "flex w-full flex-col gap-3" >
< Table
aria-label = "Shared files"
allowsResizing
sortDescriptor = {sort}
onSortChange = {setSort}
onResizeEnd = {( map ) =>
setWidths (
[ ... map. entries ()]
. map (([ k , w ]) =>
typeof w === "number"
? `${ k }: ${ Math . round ( w ) }px`
: `${ k }: ${ w }` ,
)
. join ( " · " ),
)
}
>
< TableHeader >
< Column
id = "name"
isRowHeader
allowsSorting
defaultWidth = "2fr"
minWidth = { 160 }
>
Name
</ Column >
< Column id = "owner" allowsSorting defaultWidth = "1fr" minWidth = { 120 }>
Owner
</ Column >
< Column id = "type" defaultWidth = { 96 } minWidth = { 72 }>
Type
</ Column >
< Column id = "size" allowsSorting defaultWidth = { 104 } minWidth = { 80 }>
Size
</ Column >
< Column
id = "updated"
allowsSorting
defaultWidth = { 140 }
minWidth = { 110 }
isResizable = { false }
>
Updated
</ Column >
</ TableHeader >
< TableBody items = {rows}>
{( f ) => (
< Row >
< Cell className = "font-medium" >{f.name}</ Cell >
< Cell className = "text-muted-foreground" >{f.owner}</ Cell >
< Cell >{f.type}</ Cell >
< Cell className = "tabular-nums" >{f.size. toFixed ( 1 )} MB</ Cell >
< Cell className = "text-muted-foreground" >{f.updated}</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
< p className = "text-muted-foreground text-xs" aria-live = "polite" >
{widths
? `Saved widths — ${ widths }`
: "Drag a column edge, or focus a header and press Enter to resize with the arrow keys." }
</ p >
</ div >
);
}
Search, a status filter, sortable columns, a bulk-action bar that replaces the toolbar while rows are selected, and client-side pagination with Paginator .
All statuses All statuses Paid Pending Overdue Refunded
Invoice Customer Status Issued Amount INV-1040 Acme Corp Paid 2026-09-28 $180.00 INV-1041 Soylent Refunded 2026-09-25 $3,899.00 INV-1042 Hooli Paid 2026-09-22 $3,418.00 INV-1043 Globex Overdue 2026-09-19 $2,937.00 INV-1044 Tyrell Pending 2026-09-16 $2,456.00 INV-1045 Stark Industries Paid 2026-09-13 $1,975.00
import { DownloadIcon, ListFilterIcon, TrashIcon } from "lucide-react" ;
import { useMemo, useState } from "react" ;
import type { Key, Selection, SortDescriptor } from "react-aria-components" ;
import { Badge } from "@/components/ui/badge" ;
import { Button } from "@/components/ui/button" ;
import { Paginator } from "@/components/ui/pagination" ;
import { SearchField } from "@/components/ui/search-field" ;
import { Select, SelectItem } from "@/components/ui/select" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
type Status = "Paid" | "Pending" | "Overdue" | "Refunded" ;
type Invoice = {
id : string ;
customer : string ;
status : Status ;
amount : number ;
issued : string ;
};
const customers = [
"Acme Corp" ,
"Globex" ,
"Initech" ,
"Umbrella" ,
"Hooli" ,
"Stark Industries" ,
"Wayne Enterprises" ,
"Soylent" ,
"Tyrell" ,
"Cyberdyne" ,
];
const statuses : Status [] = [
"Paid" ,
"Paid" ,
"Pending" ,
"Overdue" ,
"Paid" ,
"Refunded" ,
];
const invoices : Invoice [] = Array. from ({ length: 23 }, ( _ , i ) => ({
id: `INV-${ 1040 + i }` ,
customer: customers[(i * 7 ) % customers. length ],
status: statuses[(i * 5 ) % statuses. length ],
amount: 180 + ((i * 7919 ) % 4200 ),
issued: `2026-${ String ( 9 - Math . floor ( i / 9 )). padStart ( 2 , "0" ) }-${ String ( 28 - ( i % 9 ) * 3 ). padStart ( 2 , "0" ) }` ,
}));
const tone = {
Paid: "success" ,
Pending: "warning" ,
Overdue: "danger" ,
Refunded: "neutral" ,
} as const ;
const currency = new Intl. NumberFormat ( "en-US" , {
style: "currency" ,
currency: "USD" ,
});
const PAGE_SIZE = 6 ;
export default function TableRecipeDataTable () {
const [ query , setQuery ] = useState ( "" );
const [ status , setStatus ] = useState < Key | null >( "all" );
const [ sort , setSort ] = useState < SortDescriptor >({
column: "issued" ,
direction: "descending" ,
});
const [ selected , setSelected ] = useState < Selection >( new Set ());
const [ page , setPage ] = useState ( 1 );
const filtered = useMemo (() => {
const q = query. trim (). toLowerCase ();
const rows = invoices. filter (
( inv ) =>
(status === "all" || inv.status === status) &&
( ! q ||
inv.customer. toLowerCase (). includes (q) ||
inv.id. toLowerCase (). includes (q)),
);
const key = sort.column as keyof Invoice ;
return rows. sort (( a , b ) => {
const cmp =
typeof a[key] === "number"
? (a[key] as number ) - (b[key] as number )
: String (a[key]). localeCompare ( String (b[key]));
return sort.direction === "descending" ? - cmp : cmp;
});
}, [query, status, sort]);
const pageCount = Math. max ( 1 , Math. ceil (filtered. length / PAGE_SIZE ));
const current = Math. min (page, pageCount);
const rows = filtered. slice ((current - 1 ) * PAGE_SIZE , current * PAGE_SIZE );
const count = selected === "all" ? filtered. length : selected.size;
return (
< div className = "flex w-full max-w-3xl flex-col gap-3" >
< div className = "flex flex-wrap items-center gap-2" >
{count > 0 ? (
<>
< span className = "font-medium text-sm" >{count} selected</ span >
< Button size = "sm" variant = "outline" >
< DownloadIcon /> Export
</ Button >
< Button size = "sm" variant = "outline" color = "danger" >
< TrashIcon /> Void
</ Button >
< Button
size = "sm"
variant = "ghost"
onPress = {() => setSelected ( new Set ())}
>
Clear
</ Button >
</>
) : (
<>
< SearchField
aria-label = "Search invoices"
placeholder = "Search customer or invoice…"
size = "sm"
value = {query}
onChange = {( v ) => {
setQuery (v);
setPage ( 1 );
}}
className = "min-w-48 flex-1"
/>
< Select
aria-label = "Status"
size = "sm"
prefix = {< ListFilterIcon />}
selectedKey = {status}
onSelectionChange = {( k ) => {
setStatus (k);
setPage ( 1 );
}}
className = "w-40"
>
< SelectItem id = "all" >All statuses</ SelectItem >
< SelectItem id = "Paid" >Paid</ SelectItem >
< SelectItem id = "Pending" >Pending</ SelectItem >
< SelectItem id = "Overdue" >Overdue</ SelectItem >
< SelectItem id = "Refunded" >Refunded</ SelectItem >
</ Select >
</>
)}
</ div >
< Table
aria-label = "Invoices"
selectionMode = "multiple"
selectedKeys = {selected}
onSelectionChange = {setSelected}
sortDescriptor = {sort}
onSortChange = {setSort}
>
< TableHeader >
< Column id = "id" isRowHeader allowsSorting >
Invoice
</ Column >
< Column id = "customer" allowsSorting >
Customer
</ Column >
< Column id = "status" >Status</ Column >
< Column id = "issued" allowsSorting >
Issued
</ Column >
< Column id = "amount" allowsSorting className = "text-right" >
Amount
</ Column >
</ TableHeader >
< TableBody
items = {rows}
renderEmptyState = {() => "No invoices match your filters." }
>
{( inv ) => (
< Row >
< Cell className = "font-medium" >{inv.id}</ Cell >
< Cell >{inv.customer}</ Cell >
< Cell >
< Badge variant = "dot" color = {tone[inv.status]}>
{inv.status}
</ Badge >
</ Cell >
< Cell className = "text-muted-foreground tabular-nums" >
{inv.issued}
</ Cell >
< Cell className = "text-right tabular-nums" >
{currency. format (inv.amount)}
</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
< div className = "flex flex-wrap items-center justify-between gap-2" >
< span className = "text-muted-foreground text-xs" >
{filtered. length } invoices
</ span >
< Paginator
page = {current}
pageCount = {pageCount}
onPageChange = {setPage}
size = "sm"
/>
</ div >
</ div >
);
}
An inline role Select and a per-row action Menu . Controls inside cells are reachable with the arrow keys and labelled with the member's name.
Member Role Last active Actions OM Olivia Martin olivia@acme.com Owner Owner Admin Member Viewer
Now JL Jackson Lee jackson@acme.com Admin Owner Admin Member Viewer
2h ago IN Isabella Nguyen isabella@acme.com Member Owner Admin Member Viewer
Yesterday WK William Kim will@acme.com Viewer Owner Admin Member Viewer
Sep 12
import {
KeyRoundIcon,
MoreHorizontalIcon,
PencilIcon,
UserMinusIcon,
} from "lucide-react" ;
import { useListData } from "react-aria-components" ;
import { Avatar } from "@/components/ui/avatar" ;
import { Button } from "@/components/ui/button" ;
import {
MenuContent,
MenuItem,
MenuSeparator,
MenuTrigger,
} from "@/components/ui/menu" ;
import { Select, SelectItem } from "@/components/ui/select" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
type Member = {
id : string ;
name : string ;
email : string ;
role : string ;
lastActive : string ;
};
export default function TableRecipeRowActions () {
const members = useListData < Member >({
initialItems: [
{
id: "1" ,
name: "Olivia Martin" ,
email: "olivia@acme.com" ,
role: "owner" ,
lastActive: "Now" ,
},
{
id: "2" ,
name: "Jackson Lee" ,
email: "jackson@acme.com" ,
role: "admin" ,
lastActive: "2h ago" ,
},
{
id: "3" ,
name: "Isabella Nguyen" ,
email: "isabella@acme.com" ,
role: "member" ,
lastActive: "Yesterday" ,
},
{
id: "4" ,
name: "William Kim" ,
email: "will@acme.com" ,
role: "viewer" ,
lastActive: "Sep 12" ,
},
],
});
return (
< div className = "w-full max-w-2xl" >
< Table aria-label = "Team members" >
< TableHeader >
< Column isRowHeader >Member</ Column >
< Column className = "w-36" >Role</ Column >
< Column >Last active</ Column >
< Column className = "w-12" >
< span className = "sr-only" >Actions</ span >
</ Column >
</ TableHeader >
< TableBody items = {members.items} dependencies = {[members.items]}>
{( m ) => (
< Row >
< Cell >
< span className = "flex items-center gap-2.5" >
< Avatar
size = "sm"
colorful
alt = {m.name}
fallback = {m.name
. split ( " " )
. map (( n ) => n[ 0 ])
. join ( "" )}
/>
< span className = "flex flex-col" >
< span className = "font-medium" >{m.name}</ span >
< span className = "text-muted-foreground text-xs" >
{m.email}
</ span >
</ span >
</ span >
</ Cell >
< Cell >
< Select
aria-label = { `Role for ${ m . name }` }
size = "sm"
variant = "filled"
selectedKey = {m.role}
onSelectionChange = {( role ) =>
role && members. update (m.id, { ... m, role: String (role) })
}
isDisabled = {m.role === "owner" }
>
< SelectItem id = "owner" >Owner</ SelectItem >
< SelectItem id = "admin" >Admin</ SelectItem >
< SelectItem id = "member" >Member</ SelectItem >
< SelectItem id = "viewer" >Viewer</ SelectItem >
</ Select >
</ Cell >
< Cell className = "text-muted-foreground" >{m.lastActive}</ Cell >
< Cell >
< MenuTrigger >
< Button
size = "icon-sm"
variant = "ghost"
aria-label = { `Actions for ${ m . name }` }
>
< MoreHorizontalIcon />
</ Button >
< MenuContent
placement = "bottom end"
disabledKeys = {m.role === "owner" ? [ "remove" ] : []}
>
< MenuItem id = "edit" textValue = "Edit profile" >
< PencilIcon /> Edit profile
</ MenuItem >
< MenuItem id = "reset" textValue = "Reset password" >
< KeyRoundIcon /> Reset password
</ MenuItem >
< MenuSeparator />
< MenuItem
id = "remove"
textValue = "Remove from team"
variant = "destructive"
onAction = {() => members. remove (m.id)}
>
< UserMinusIcon /> Remove from team
</ MenuItem >
</ MenuContent >
</ MenuTrigger >
</ Cell >
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
A Checkbox in every cell, bordered grid lines and compact density. Each checkbox's aria-label names both the permission and the role.
Permission Viewer Editor Admin View projects Edit content Publish Manage billing
import { useState } from "react" ;
import { Checkbox } from "@/components/ui/checkbox" ;
import {
Cell,
Column,
Row,
Table,
TableBody,
TableHeader,
} from "@/components/ui/table" ;
const roles = [ "Viewer" , "Editor" , "Admin" ] as const ;
const permissions = [
{ id: "view" , name: "View projects" , defaults: [ true , true , true ] },
{ id: "comment" , name: "Comment" , defaults: [ true , true , true ] },
{ id: "edit" , name: "Edit content" , defaults: [ false , true , true ] },
{ id: "publish" , name: "Publish" , defaults: [ false , false , true ] },
{ id: "billing" , name: "Manage billing" , defaults: [ false , false , true ] },
];
export default function TableRecipePermissions () {
const [ grants , setGrants ] = useState (
() => new Map (permissions. map (( p ) => [p.id, p.defaults])),
);
const toggle = ( id : string , roleIndex : number , value : boolean ) =>
setGrants (( prev ) => {
const next = new Map (prev);
const row = [ ... (next. get (id) ?? [])];
row[roleIndex] = value;
next. set (id, row);
return next;
});
return (
< div className = "w-full max-w-lg" >
< Table aria-label = "Role permissions" bordered density = "compact" >
< TableHeader >
< Column isRowHeader >Permission</ Column >
{roles. map (( role ) => (
< Column key = {role} className = "w-20 text-center" >
{role}
</ Column >
))}
</ TableHeader >
< TableBody items = {permissions} dependencies = {[grants]}>
{( p ) => (
< Row >
< Cell >{p.name}</ Cell >
{roles. map (( role , i ) => (
< Cell key = {role} className = "text-center" >
< Checkbox
aria-label = { `${ p . name } for ${ role }` }
isSelected = {grants. get (p.id)?.[i] ?? false }
onChange = {( v ) => toggle (p.id, i, v)}
isDisabled = {role === "Admin" && p.id === "view" }
className = "inline-flex"
/>
</ Cell >
))}
</ Row >
)}
</ TableBody >
</ Table >
</ div >
);
}
The table is a role="grid": users move between cells with the arrow keys, and focus is a single tab stop.
Label the table with aria-label or aria-labelledby, and mark one column isRowHeader.
Sortable columns announce their sort direction with aria-sort.
The selection checkboxes are labelled automatically ("Select", "Select all") and linked to the row header.
Controls inside cells (buttons, selects, checkboxes) are reachable by arrowing into the cell. Give icon-only buttons an aria-label that names the row.
Visually hidden column labels (like "Actions") keep header cells meaningful for screen readers.
Drag and drop has keyboard and screen reader support built in.
Key Action Tab Moves focus into and out of the table ↑ / ↓ Moves focus to the row or cell above / below ← / → Moves focus between cells in a row (and into a cell's controls) Home / End Moves focus to the first / last cell in the row Ctrl +Home / Ctrl +End Moves focus to the first / last row Page Up / Page Down Moves focus by a page Space Toggles selection of the focused row Enter Sorts by the focused column, or triggers the row action / link Shift +↑ / Shift +↓ Extends the selection (multiple) ⌘ /Ctrl +A Selects all rows (multiple) Esc Clears the selection
Attribute On Present when data-focused / data-focus-visibleTable, Column, Row, CellFocused / keyboard focused data-drop-targetTable, TableBody, RowA drag is over it data-hoveredTableHeader, Column, Row, CellHovered with a mouse data-pressedColumn, Row, CellBeing pressed data-allows-sortingColumnallowsSorting is setdata-sort-directionColumnascending or descending when sorted by this columndata-emptyTableBodyThere are no rows data-selectedRow, CellThe row is selected data-disabledRow, CellThe row is disabled data-selection-modeRowsingle or multipledata-focus-visible-withinRowA cell or control in the row has keyboard focus data-dragging / data-allows-draggingRowThe row is being dragged / drag and drop is enabled
Column and Row are Tailwind groups (group/column, group/row), so children can use group-data-selected/row: or group-data-hovered/column:.
data-slotElement table-containerScroll container and frame table<table>table-header / table-columnHeader row and header cells table-body / table-row / table-cellBody, rows and cells
className on Table styles the <table> element (for example min-w-[640px] to force horizontal scroll). The container is always w-full overflow-auto.
Cells don't wrap by default. Add whitespace-normal to a Cell for long text, or max-w-* truncate to clip it.
Set column widths with w-* classes on Column.
Every part's className also accepts a function of its render props.
Column resizing and expandable tree rows aren't part of this component.
Also accepts every prop of React Aria's Table .
Grid List — single-column interactive lists and cards.
Pagination — page controls for long tables.
Menu — row action menus and column pickers.
Checkbox — the selection control used in rows.