Table
Rows of records with the same columns, with numbers aligned and a header that stays pinned.
import { Table } from '@/components/panelui/table';
const INVOICES = [
{ id: 'INV-2041', client: 'Larkspur Joinery', due: '4 Nov', amount: '£1,280.00' },
{ id: 'INV-2042', client: 'Harbour Bakery', due: '9 Nov', amount: '£312.50' },
{ id: 'INV-2043', client: 'Northgate Clinic', due: '12 Nov', amount: '£4,906.00' },
{ id: 'INV-2044', client: 'Fen Lane Cycles', due: '20 Nov', amount: '£86.40' },
];
export default function TableOverview() {
return (
<Table className="min-w-md">
<Table.Header>
<Table.Row>
<Table.Head>Invoice</Table.Head>
<Table.Head>Client</Table.Head>
<Table.Head>Due</Table.Head>
<Table.Head align="end">Amount</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{INVOICES.map((invoice) => (
<Table.Row key={invoice.id}>
<Table.Cell className="font-medium">{invoice.id}</Table.Cell>
<Table.Cell>{invoice.client}</Table.Cell>
<Table.Cell className="text-muted-foreground">{invoice.due}</Table.Cell>
<Table.Cell align="end">{invoice.amount}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
);
}Use a table for records that share the same columns and are compared down a column: invoices, stock, readings.
For a list of things with one or two details each, Frame rows read better on a narrow screen.
A table wider than its container scrolls sideways inside its own box.
Installation
npx shadcn@latest add https://panelui.dev/r/web/table.jsonpnpm dlx shadcn@latest add https://panelui.dev/r/web/table.jsonyarn dlx shadcn@latest add https://panelui.dev/r/web/table.jsonbunx shadcn@latest add https://panelui.dev/r/web/table.jsonThe source is copied into components/panelui/, where you can edit it.
Usage
import { Table } from "@/components/panelui/table"<Table>
<Table.Header>
<Table.Row>
<Table.Head>Invoice</Table.Head>
<Table.Head align="end">Amount</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell>INV-2041</Table.Cell>
<Table.Cell align="end">£1,280.00</Table.Cell>
</Table.Row>
</Table.Body>
</Table>Composition
<Table>
<Table.Caption />
<Table.Header>
<Table.Row>
<Table.Head />
</Table.Row>
</Table.Header>
<Table.Body>
<Table.Row>
<Table.Cell />
</Table.Row>
</Table.Body>
<Table.Footer />
</Table>Examples
Compact and striped
density="compact" tightens the rows for a dense screen. striped shades every other row, which helps keep your place across a wide row.
import { Badge } from '@/components/panelui/badge';
import { Table } from '@/components/panelui/table';
const STOCK = [
{ sku: 'OAK-12', item: 'Oak board, 12mm', bin: 'A3', qty: 148, state: 'ok' },
{ sku: 'OAK-18', item: 'Oak board, 18mm', bin: 'A4', qty: 22, state: 'low' },
{ sku: 'ASH-18', item: 'Ash board, 18mm', bin: 'B1', qty: 0, state: 'out' },
{ sku: 'BIR-09', item: 'Birch ply, 9mm', bin: 'C2', qty: 310, state: 'ok' },
{ sku: 'BIR-15', item: 'Birch ply, 15mm', bin: 'C3', qty: 64, state: 'ok' },
{ sku: 'WAL-20', item: 'Walnut slab, 20mm', bin: 'D1', qty: 7, state: 'low' },
] as const;
const STATE = {
ok: { label: 'In stock', variant: 'success' },
low: { label: 'Low', variant: 'warning' },
out: { label: 'Out', variant: 'destructive' },
} as const;
export default function TableDense() {
return (
<Table density="compact" striped className="min-w-md">
<Table.Header>
<Table.Row>
<Table.Head>SKU</Table.Head>
<Table.Head>Item</Table.Head>
<Table.Head>Bin</Table.Head>
<Table.Head align="end">Qty</Table.Head>
<Table.Head align="end">Status</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{STOCK.map((row) => (
<Table.Row key={row.sku}>
<Table.Cell className="font-mono text-xs">{row.sku}</Table.Cell>
<Table.Cell>{row.item}</Table.Cell>
<Table.Cell className="text-muted-foreground">{row.bin}</Table.Cell>
<Table.Cell align="end">{row.qty}</Table.Cell>
<Table.Cell align="end">
<Badge size="sm" variant={STATE[row.state].variant}>
{STATE[row.state].label}
</Badge>
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
);
}Pinned header
maxHeight makes the body scroll, with the header pinned above it. framed keeps the first and last columns off the edge of a bordered box.
import { Table } from '@/components/panelui/table';
const READINGS = Array.from({ length: 24 }, (_, hour) => {
const temp = 11 + Math.round(6 * Math.sin(((hour - 9) / 24) * Math.PI * 2) * 10) / 10;
const wind = 8 + ((hour * 7) % 11);
const rain = hour >= 15 && hour <= 18 ? ((hour * 3) % 5) / 2 + 0.5 : 0;
return { time: `${String(hour).padStart(2, '0')}:00`, temp, wind, rain };
});
export default function TableScroll() {
return (
<Table maxHeight="16rem" containerClassName="w-full max-w-md rounded-lg border" framed>
<Table.Caption className="pb-3">Hourly readings, weather station 4</Table.Caption>
<Table.Header>
<Table.Row>
<Table.Head>Time</Table.Head>
<Table.Head align="end">Temp °C</Table.Head>
<Table.Head align="end">Wind km/h</Table.Head>
<Table.Head align="end">Rain mm</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{READINGS.map((row) => (
<Table.Row key={row.time}>
<Table.Cell className="tabular-nums">{row.time}</Table.Cell>
<Table.Cell align="end">{row.temp.toFixed(1)}</Table.Cell>
<Table.Cell align="end">{row.wind}</Table.Cell>
<Table.Cell align="end">{row.rain.toFixed(1)}</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
);
}In a Frame
In a panel with no padding, framed lines the columns up with the panel's header.
import { Frame } from '@/components/panelui/frame';
import { Table } from '@/components/panelui/table';
const TEAM = [
{ name: 'Ife Adeyemi', role: 'Owner', seats: 'All projects', last: 'Now' },
{ name: 'Tomás Mendes', role: 'Editor', seats: '3 projects', last: '2 h ago' },
{ name: 'Hana Kato', role: 'Viewer', seats: '1 project', last: 'Yesterday' },
];
export default function TableFrame() {
return (
<Frame className="w-full max-w-lg">
<Frame.Header>
<Frame.Title>Members</Frame.Title>
<Frame.Action>3 of 5 seats</Frame.Action>
</Frame.Header>
<Frame.Panel className="p-0">
<Table framed>
<Table.Header>
<Table.Row>
<Table.Head>Name</Table.Head>
<Table.Head>Role</Table.Head>
<Table.Head>Access</Table.Head>
<Table.Head align="end">Last seen</Table.Head>
</Table.Row>
</Table.Header>
<Table.Body>
{TEAM.map((person) => (
<Table.Row key={person.name}>
<Table.Cell className="font-medium">{person.name}</Table.Cell>
<Table.Cell>{person.role}</Table.Cell>
<Table.Cell className="text-muted-foreground">{person.seats}</Table.Cell>
<Table.Cell align="end" className="text-muted-foreground">
{person.last}
</Table.Cell>
</Table.Row>
))}
</Table.Body>
</Table>
</Frame.Panel>
</Frame>
);
}API Reference
Table
| Prop | Type | Default | Description |
|---|---|---|---|
density | TableDensity | 'comfortable' | Row height. compact for a dense screen. Default comfortable. |
striped | boolean | false | Shades every other body row. |
framed | boolean | false | Draws the cells clear of the edges, for a table set inside a Card or Frame panel, whose edge the first and last columns would otherwise touch. |
maxHeight | CSSProperties['maxHeight'] | — | The tallest the table gets before its body scrolls, with the header pinned above. Any CSS length. |
containerClassName | string | — | Classes for the scrolling box around the table, rather than the table. |
Table.Head
| Prop | Type | Default | Description |
|---|---|---|---|
align | TableAlign | start | Where the column's content sits. end for numbers. Default start. |
Table.Cell
| Prop | Type | Default | Description |
|---|---|---|---|
align | TableAlign | start | Where the content sits. Match the column's head. end also gives the digits equal widths. Default start. |
Every part also takes the props of the element it renders, and a className.
Notes
Numbers
align="end" on a column's head and cells right-aligns it and gives the digits equal widths, so amounts line up on their last digit. Set it on both.
Accessibility
Every part is the native table element, and Table.Head has scope="col", so a screen reader announces each cell's column. Table.Caption names the table.
Server Components
Table is a client component. In a file that starts with "use client", write its parts with a dot, as on this page. A Server Component cannot read Table.Header off the import, so each part is also exported by name: TableHeader is Table.Header, and so on for the others.
import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption } from "@/components/panelui/table"