Table
Shows data in rows and columns, with sortable headers.
| Project | Owner |
|---|---|
| Studio North | Maya Chen |
| Atlas | Sam Rivera |
| Field Notes | Jo Park |
"use client";import { Cell, Column, Row, Table, TableBody, TableHeader,} from "@/components/vip-ui/table";const projects = [ { id: "north", name: "Studio North", owner: "Maya Chen" }, { id: "atlas", name: "Atlas", owner: "Sam Rivera" }, { id: "field", name: "Field Notes", owner: "Jo Park" },];export function TableBasicDemo() { return ( <div className="w-full max-w-md overflow-x-auto rounded-lg border border-border"> <Table aria-label="Projects"> <TableHeader> <Column isRowHeader>Project</Column> <Column>Owner</Column> </TableHeader> <TableBody items={projects}> {(project) => ( <Row id={project.id}> <Cell>{project.name}</Cell> <Cell>{project.owner}</Cell> </Row> )} </TableBody> </Table> </div> );}Installation
The CLI adds the files, packages, theme variables, and Tailwind plugin that this component uses. Before you run it, set up your project.
pnpm dlx shadcn@latest add https://vip-ui.dev/r/vip-table.jsonComposition
TableContainerOptionalScrolls the table and keeps the header in view. Column resizing needs it.
TableOwns selection, sorting, expanded rows, and drag and drop.
TableHeaderAdds the select-all and drag columns when the table needs them.
ColumnRepeatsSet isRowHeader on the column that names each row.
ColumnResizerOptional
TableBodyRowRepeatsAdds the checkbox and drag handle cells. Put child rows after the cells to make the row expandable.
CellRepeatsIts text stays on one line. Add whitespace-normal to let it wrap.
TableLoadMoreItemOptional
TableFooterOptionalRowRepeats
TableDropIndicator Return it from renderDropIndicator in useDragAndDrop.
Multiple selection
selectionMode="multiple" adds a checkbox column. A disabled row cannot be selected. Also install Checkbox and Badge.
| Project | Owner | Status | |
|---|---|---|---|
| Studio North | Maya Chen | In progress | |
| Atlas | Sam Rivera | Review | |
| Field Notes | Jo Park | Published | |
| Signal | Maya Chen | In progress | |
| Morrow | Alex Kim | Review | |
| Orbit | Sam Rivera | Published |
"use client";import { useState } from "react";import type { Selection } from "react-aria-components";import { Badge } from "@/components/vip-ui/badge";import { Cell, Column, Row, Table, TableBody, TableContainer, TableHeader,} from "@/components/vip-ui/table";const projects = [ { id: "north", name: "Studio North", owner: "Maya Chen", status: "In progress", }, { id: "atlas", name: "Atlas", owner: "Sam Rivera", status: "Review" }, { id: "field", name: "Field Notes", owner: "Jo Park", status: "Published" }, { id: "signal", name: "Signal", owner: "Maya Chen", status: "In progress" }, { id: "morrow", name: "Morrow", owner: "Alex Kim", status: "Review" }, { id: "orbit", name: "Orbit", owner: "Sam Rivera", status: "Published" },] as const;const statusVariants = { "In progress": "accent", Review: "warning", Published: "success",} as const;export function TableDemo() { const [selected, setSelected] = useState<Selection>(new Set(["atlas"])); const count = selected === "all" ? projects.length - 1 : selected.size; return ( <div className="grid w-full max-w-2xl gap-2"> <TableContainer> <Table aria-label="Projects" selectionMode="multiple" selectedKeys={selected} onSelectionChange={setSelected} disabledKeys={["orbit"]} className="min-w-[30rem]" > <TableHeader> <Column id="name" isRowHeader> Project </Column> <Column id="owner">Owner</Column> <Column id="status">Status</Column> </TableHeader> <TableBody items={projects}> {(project) => ( <Row id={project.id}> <Cell> <span className="font-medium">{project.name}</span> </Cell> <Cell>{project.owner}</Cell> <Cell> <Badge variant={statusVariants[project.status]} dot> {project.status} </Badge> </Cell> </Row> )} </TableBody> </Table> </TableContainer> <output aria-live="polite" className="text-sm text-muted-foreground tabular-nums" > {count === 0 ? "No projects selected" : `${count} selected`} </output> </div> );}Sortable columns
Table does not sort the rows. Sort them in onSortChange.
| Project | Owner | Open tasks |
|---|---|---|
| Atlas | Sam Rivera | 8 |
| Field Notes | Jo Park | 3 |
| Grain | Jo Park | 0 |
| Horizon | Alex Kim | 14 |
| Morrow | Alex Kim | 5 |
| Orbit | Sam Rivera | 21 |
| Signal | Maya Chen | 18 |
| Studio North | Maya Chen | 12 |
"use client";import { useMemo, useState } from "react";import type { SortDescriptor } from "react-aria-components";import { Cell, Column, Row, Table, TableBody, TableHeader,} from "@/components/vip-ui/table";const projects = [ { id: "north", name: "Studio North", owner: "Maya Chen", openTasks: 12 }, { id: "atlas", name: "Atlas", owner: "Sam Rivera", openTasks: 8 }, { id: "field", name: "Field Notes", owner: "Jo Park", openTasks: 3 }, { id: "signal", name: "Signal", owner: "Maya Chen", openTasks: 18 }, { id: "morrow", name: "Morrow", owner: "Alex Kim", openTasks: 5 }, { id: "orbit", name: "Orbit", owner: "Sam Rivera", openTasks: 21 }, { id: "grain", name: "Grain", owner: "Jo Park", openTasks: 0 }, { id: "horizon", name: "Horizon", owner: "Alex Kim", openTasks: 14 },];export function TableSortingDemo() { const [sortDescriptor, setSortDescriptor] = useState<SortDescriptor>({ column: "name", direction: "ascending", }); const sorted = useMemo( () => [...projects].sort((a, b) => { const result = sortDescriptor.column === "openTasks" ? a.openTasks - b.openTasks : a[ sortDescriptor.column === "owner" ? "owner" : "name" ].localeCompare( b[sortDescriptor.column === "owner" ? "owner" : "name"], ); return sortDescriptor.direction === "descending" ? -result : result; }), [sortDescriptor], ); return ( <div className="w-full max-w-2xl overflow-x-auto rounded-lg border border-border bg-card shadow-[var(--shadow-card)]"> <Table aria-label="Project backlog" sortDescriptor={sortDescriptor} onSortChange={setSortDescriptor} > <TableHeader> <Column id="name" isRowHeader allowsSorting> Project </Column> <Column id="owner" allowsSorting> Owner </Column> <Column id="openTasks" allowsSorting className="text-end"> Open tasks </Column> </TableHeader> <TableBody items={sorted}> {(project) => ( <Row id={project.id}> <Cell>{project.name}</Cell> <Cell>{project.owner}</Cell> <Cell className="text-end tabular-nums">{project.openTasks}</Cell> </Row> )} </TableBody> </Table> </div> );}Filter invoices
renderEmptyState shows a message when no rows match.
| Invoice | Customer | Amount | Status |
|---|---|---|---|
| INV-1048 | Acme Studio | $2,400 | Paid |
| INV-1049 | Northstar Labs | $1,180 | Overdue |
| INV-1050 | Fieldwork Co. | $3,750 | Pending |
| INV-1051 | Meridian Health | $890 | Paid |
| INV-1052 | Paperplane | $1,560 | Pending |
| INV-1053 | Atlas Works | $4,200 | Overdue |
| INV-1054 | Goodside | $640 | Paid |
| INV-1055 | Morrow Design | $2,100 | Pending |
"use client";import { MagnifyingGlassIcon } from "@phosphor-icons/react";import { useState } from "react";import { Badge } from "@/components/vip-ui/badge";import { SearchField } from "@/components/vip-ui/search-field";import { Select } from "@/components/vip-ui/select";import { Cell, Column, Row, Table, TableBody, TableHeader,} from "@/components/vip-ui/table";const statusVariants = { Paid: "success", Pending: "accent", Overdue: "warning",} as const;type Invoice = { id: string; customer: string; amount: number; status: keyof typeof statusVariants;};const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0,});const invoices: Invoice[] = [ { id: "INV-1048", customer: "Acme Studio", amount: 2400, status: "Paid" }, { id: "INV-1049", customer: "Northstar Labs", amount: 1180, status: "Overdue", }, { id: "INV-1050", customer: "Fieldwork Co.", amount: 3750, status: "Pending", }, { id: "INV-1051", customer: "Meridian Health", amount: 890, status: "Paid", }, { id: "INV-1052", customer: "Paperplane", amount: 1560, status: "Pending", }, { id: "INV-1053", customer: "Atlas Works", amount: 4200, status: "Overdue", }, { id: "INV-1054", customer: "Goodside", amount: 640, status: "Paid" }, { id: "INV-1055", customer: "Morrow Design", amount: 2100, status: "Pending", },];export function TableFilterDemo() { const [query, setQuery] = useState(""); const [status, setStatus] = useState("all"); const results = invoices.filter( (invoice) => (status === "all" || invoice.status === status) && `${invoice.id} ${invoice.customer}` .toLowerCase() .includes(query.trim().toLowerCase()), ); return ( <div className="w-full max-w-3xl space-y-3"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end"> <SearchField label="Search invoices" placeholder="Invoice number or customer" value={query} onChange={setQuery} className="sm:flex-1" /> <Select label="Status" value={status} onValueChange={setStatus} options={[ { id: "all", name: "All statuses" }, { id: "Paid", name: "Paid" }, { id: "Pending", name: "Pending" }, { id: "Overdue", name: "Overdue" }, ]} className="sm:max-w-48" /> </div> <output className="block text-xs text-muted-foreground"> {results.length} of {invoices.length} invoices </output> <div className="overflow-x-auto rounded-lg border border-border bg-card shadow-[var(--shadow-card)]"> <Table aria-label="Filtered invoices" className="min-w-[540px]"> <TableHeader> <Column id="invoice" isRowHeader> Invoice </Column> <Column id="customer">Customer</Column> <Column id="amount" className="text-end"> Amount </Column> <Column id="status">Status</Column> </TableHeader> <TableBody items={results} renderEmptyState={() => ( <div className="flex flex-col items-center gap-2 px-4 py-8 text-center"> <MagnifyingGlassIcon size={20} aria-hidden="true" className="text-muted-foreground" /> <p className="text-sm font-medium">No invoices found</p> <p className="text-xs text-muted-foreground"> Try another customer or status. </p> </div> )} > {(invoice) => ( <Row id={invoice.id}> <Cell> <span className="font-medium">{invoice.id}</span> </Cell> <Cell>{invoice.customer}</Cell> <Cell className="text-end tabular-nums"> {currency.format(invoice.amount)} </Cell> <Cell> <Badge variant={statusVariants[invoice.status]} dot> {invoice.status} </Badge> </Cell> </Row> )} </TableBody> </Table> </div> </div> );}Resizable columns
Put the table in TableContainer. Drag a column edge, or focus it and use the arrow keys.
| Name | Owner | Note | Size |
|---|---|---|---|
| Brand brief.pdf | Maya Chen | Final copy for the spring campaign | 2.4 MB |
| Accessibility audit.xlsx | Jo Park | Contrast and focus findings from the last review | 860 KB |
| Roadmap Q3.key | Sam Rivera | Draft. Needs dates from the platform team | 14.1 MB |
| Invoice 2041.pdf | Alex Kim | Paid on 4 June | 120 KB |
"use client";import { Cell, Column, Row, Table, TableBody, TableContainer, TableHeader,} from "@/components/vip-ui/table";const files = [ { id: "brief", name: "Brand brief.pdf", owner: "Maya Chen", note: "Final copy for the spring campaign", size: "2.4 MB", }, { id: "audit", name: "Accessibility audit.xlsx", owner: "Jo Park", note: "Contrast and focus findings from the last review", size: "860 KB", }, { id: "roadmap", name: "Roadmap Q3.key", owner: "Sam Rivera", note: "Draft. Needs dates from the platform team", size: "14.1 MB", }, { id: "invoice", name: "Invoice 2041.pdf", owner: "Alex Kim", note: "Paid on 4 June", size: "120 KB", },];export function TableResizableDemo() { return ( <TableContainer className="max-w-2xl"> <Table aria-label="Shared files"> <TableHeader> <Column id="name" isRowHeader allowsResizing defaultWidth="1.4fr" minWidth={140} > Name </Column> <Column id="owner" allowsResizing defaultWidth="1fr" minWidth={110}> Owner </Column> <Column id="note" allowsResizing defaultWidth="2fr" minWidth={140}> Note </Column> <Column id="size" defaultWidth={96} className="text-end"> Size </Column> </TableHeader> <TableBody items={files}> {(file) => ( <Row id={file.id}> <Cell className="truncate font-medium">{file.name}</Cell> <Cell className="truncate">{file.owner}</Cell> <Cell className="truncate text-muted-foreground"> {file.note} </Cell> <Cell className="text-end tabular-nums">{file.size}</Cell> </Row> )} </TableBody> </Table> </TableContainer> );}Expandable rows
Put child rows after the cells of a row, and set treeColumn to the column that shows the expand button.
| Task | Owner | Estimate |
|---|---|---|
| Website launch | Maya Chen | 46 h |
| Design | Jo Park | 18 h |
| Build | Sam Rivera | 22 h |
| Quality check | Alex Kim | 6 h |
| Help center | Alex Kim | 12 h |
"use client";import { Cell, Column, Row, Table, TableBody, TableContainer, TableHeader,} from "@/components/vip-ui/table";interface Task { id: string; name: string; owner: string; hours: number; children?: Task[];}const tasks: Task[] = [ { id: "launch", name: "Website launch", owner: "Maya Chen", hours: 46, children: [ { id: "design", name: "Design", owner: "Jo Park", hours: 18, children: [ { id: "wire", name: "Wireframes", owner: "Jo Park", hours: 6 }, { id: "visual", name: "Visual design", owner: "Jo Park", hours: 12 }, ], }, { id: "build", name: "Build", owner: "Sam Rivera", hours: 22 }, { id: "qa", name: "Quality check", owner: "Alex Kim", hours: 6 }, ], }, { id: "docs", name: "Help center", owner: "Alex Kim", hours: 12, children: [ { id: "outline", name: "Outline", owner: "Alex Kim", hours: 4 }, { id: "write", name: "Write articles", owner: "Maya Chen", hours: 8 }, ], },];function TaskRow({ task }: { task: Task }) { return ( <Row id={task.id}> <Cell> <span className="font-medium">{task.name}</span> </Cell> <Cell>{task.owner}</Cell> <Cell className="text-end tabular-nums">{task.hours} h</Cell> {task.children?.map((child) => ( <TaskRow key={child.id} task={child} /> ))} </Row> );}export function TableExpandableDemo() { return ( <TableContainer className="max-w-2xl"> <Table aria-label="Project tasks" treeColumn="name" defaultExpandedKeys={["launch"]} className="min-w-[30rem]" > <TableHeader> <Column id="name" isRowHeader> Task </Column> <Column id="owner">Owner</Column> <Column id="hours" className="text-end"> Estimate </Column> </TableHeader> <TableBody> {tasks.map((task) => ( <TaskRow key={task.id} task={task} /> ))} </TableBody> </Table> </TableContainer> );}Reorder rows
Pass dragAndDropHooks to add a drag handle column. To move a row with the keyboard, press Right Arrow to go to the handle, press Enter, then use the arrow keys. Also install Checkbox.
| Reorder | Task | Owner | |
|---|---|---|---|
| Write the release notes | Maya Chen | ||
| Update the pricing page | Sam Rivera | ||
| Record the demo video | Jo Park | ||
| Send the newsletter | Alex Kim |
"use client";import { useDragAndDrop, useListData } from "react-aria-components";import { Cell, Column, Row, Table, TableBody, TableContainer, TableDropIndicator, TableHeader,} from "@/components/vip-ui/table";export function TableReorderDemo() { const list = useListData({ initialItems: [ { id: "1", name: "Write the release notes", owner: "Maya Chen" }, { id: "2", name: "Update the pricing page", owner: "Sam Rivera" }, { id: "3", name: "Record the demo video", owner: "Jo Park" }, { id: "4", name: "Send the newsletter", owner: "Alex Kim" }, ], }); const { dragAndDropHooks } = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({ "text/plain": list.getItem(key)?.name ?? "", })), onReorder(event) { if (event.target.dropPosition === "before") { list.moveBefore(event.target.key, event.keys); } else if (event.target.dropPosition === "after") { list.moveAfter(event.target.key, event.keys); } }, renderDropIndicator: (target) => <TableDropIndicator target={target} />, }); return ( <TableContainer className="max-w-2xl"> <Table aria-label="Launch checklist" selectionMode="multiple" dragAndDropHooks={dragAndDropHooks} className="min-w-[28rem]" > <TableHeader> <Column id="name" isRowHeader> Task </Column> <Column id="owner">Owner</Column> </TableHeader> <TableBody items={list.items}> {(item) => ( <Row id={item.id}> <Cell>{item.name}</Cell> <Cell>{item.owner}</Cell> </Row> )} </TableBody> </Table> </TableContainer> );}Load more
TableLoadMoreItem calls onLoadMore when it scrolls into view. The header stays at the top. Also install Spinner.
| Order | Customer | Total |
|---|---|---|
"use client";import { Collection, useAsyncList } from "react-aria-components";import { Cell, Column, Row, Table, TableBody, TableContainer, TableHeader, TableLoadMoreItem,} from "@/components/vip-ui/table";interface Order { id: string; customer: string; total: number;}const customers = [ "Maya Chen", "Sam Rivera", "Jo Park", "Alex Kim", "Priya Nair", "Leo Novak",];function fetchOrders(cursor: number, signal: AbortSignal) { return new Promise<{ items: Order[]; cursor?: string }>((resolve, reject) => { const timer = setTimeout(() => { const items = Array.from({ length: 10 }, (_, index) => { const n = cursor + index; return { id: `ORD-${1001 + n}`, customer: customers[n % customers.length], total: 40 + ((n * 37) % 460), }; }); const next = cursor + items.length; resolve({ items, cursor: next < 40 ? String(next) : undefined }); }, 700); signal.addEventListener("abort", () => { clearTimeout(timer); reject(signal.reason); }); });}const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD",});export function TableLoadMoreDemo() { const list = useAsyncList<Order, string>({ load: ({ signal, cursor }) => fetchOrders(Number(cursor ?? 0), signal), }); return ( <TableContainer className="max-h-80 max-w-2xl"> <Table aria-label="Orders" className="min-w-[26rem]"> <TableHeader> <Column id="id" isRowHeader> Order </Column> <Column id="customer">Customer</Column> <Column id="total" className="text-end"> Total </Column> </TableHeader> <TableBody> <Collection items={list.items}> {(order) => ( <Row id={order.id}> <Cell className="font-medium tabular-nums">{order.id}</Cell> <Cell>{order.customer}</Cell> <Cell className="text-end tabular-nums"> {currency.format(order.total)} </Cell> </Row> )} </Collection> <TableLoadMoreItem onLoadMore={list.loadMore} isLoading={list.loadingState === "loadingMore"} /> </TableBody> </Table> </TableContainer> );}Footer
| Item | Hours | Amount |
|---|---|---|
| Design system audit | 12 | $1,680 |
| Component build | 32 | $3,840 |
| Documentation | 8 | $760 |
| Total | 52 | $6,280 |
"use client";import { Cell, Column, Row, Table, TableBody, TableContainer, TableFooter, TableHeader,} from "@/components/vip-ui/table";const lines = [ { id: "design", item: "Design system audit", hours: 12, rate: 140 }, { id: "build", item: "Component build", hours: 32, rate: 120 }, { id: "docs", item: "Documentation", hours: 8, rate: 95 },];const currency = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0,});export function TableFooterDemo() { const hours = lines.reduce((sum, line) => sum + line.hours, 0); const total = lines.reduce((sum, line) => sum + line.hours * line.rate, 0); return ( <TableContainer className="max-w-2xl"> <Table aria-label="Invoice lines" className="min-w-[28rem]"> <TableHeader> <Column id="item" isRowHeader> Item </Column> <Column id="hours" className="text-end"> Hours </Column> <Column id="amount" className="text-end"> Amount </Column> </TableHeader> <TableBody items={lines}> {(line) => ( <Row id={line.id}> <Cell>{line.item}</Cell> <Cell className="text-end tabular-nums">{line.hours}</Cell> <Cell className="text-end tabular-nums"> {currency.format(line.hours * line.rate)} </Cell> </Row> )} </TableBody> <TableFooter> <Row id="total"> <Cell>Total</Cell> <Cell className="text-end tabular-nums">{hours}</Cell> <Cell className="text-end tabular-nums"> {currency.format(total)} </Cell> </Row> </TableFooter> </Table> </TableContainer> );}API reference
vip/ui adds or changes the props below. For all other props, see React Aria Table (opens in a new tab).
Column
- allowsResizing: boolean = false
- Adds a resize handle at the end of the column. The table must be in TableContainer.