Table
The table parts: a scrolling container, a ruled header, hover rows and a caption.
Vibra rules the header row with the heavier --rule and sets its cells in the 13px label register in muted ink, where shadcn's are foreground on the row hairline; a selected row takes the accent-muted fill, a 2px accent rail and medium weight — three cues, never colour alone. Column titles start at the inline start, a checkbox column trims its inline end, and the rail sits on the row's inline start, so a right-to-left table mirrors without a class of its own. Cells set tabular figures through the theme; right-align a numeric column's title and cells together. Name a table by its caption or, beside a visible heading, with aria-label. A row keeps the hover plane while a menu in it is open. The box a table sits in scrolls it sideways when it is wider than the page, and the arrow keys scroll only what has the focus: while the table overflows, the box is a tab stop and a region named after the table — its aria-labelledby, its aria-label, else its TableCaption, which takes an id of its own for this — with the focus ring drawn inside its edge — or a group of that name when the card or band around it is already a region called the same, so a page never holds two landmarks of one name; while it fits, the box is neither, so it never costs a Tab press that scrolls nothing. It measures itself with a ResizeObserver, so a narrower window or a wider page of rows turns it on. For sorting, filters and paging over a data set, reach for DataTable.
Install
npx shadcn@latest add @vibra/tableNeeds the @vibra registry in your components.json — set it up once.
Examples
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const INVOICES = [
{ id: "INV-2041", customer: "Harbour Logistics", status: "Paid", amount: 4200 },
{ id: "INV-2042", customer: "Keystone Dental", status: "Paid", amount: 1850 },
{ id: "INV-2043", customer: "Riverside Studio", status: "Overdue", amount: 960 },
]
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
export default function TableDemo() {
const total = INVOICES.reduce((sum, invoice) => sum + invoice.amount, 0)
return (
<Table className="w-full max-w-xl">
<TableCaption>September invoices for Northwind.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((invoice) => (
<TableRow key={invoice.id} data-state={invoice.id === "INV-2043" ? "selected" : undefined}>
<TableCell className="font-mono text-xs">{invoice.id}</TableCell>
<TableCell>{invoice.customer}</TableCell>
<TableCell>{invoice.status}</TableCell>
<TableCell className="text-right tabular-nums">{money.format(invoice.amount)}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell className="text-right tabular-nums">{money.format(total)}</TableCell>
</TableRow>
</TableFooter>
</Table>
)
}Sortable columns
Each title is a button and its header cell carries aria-sort; the caption says the order in words.
"use client"
import * as React from "react"
import { ArrowDownIcon, ArrowUpIcon, ChevronsUpDownIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type Account = { company: string; country: string; seats: number; mrr: number }
const ACCOUNTS: Account[] = [
{ company: "Wavelength Retail", country: "Sweden", seats: 384, mrr: 18432 },
{ company: "Lumen Studio", country: "United States", seats: 372, mrr: 17856 },
{ company: "Beacon Retail", country: "United States", seats: 370, mrr: 17760 },
{ company: "Ironwood Studio", country: "Australia", seats: 360, mrr: 17280 },
{ company: "Fathom Energy", country: "Canada", seats: 359, mrr: 17232 },
{ company: "Cobalt Digital", country: "India", seats: 342, mrr: 16416 },
]
// Country is the column that steps aside on a phone.
const COLUMNS = [
{ key: "company", label: "Company", numeric: false, phone: true },
{ key: "country", label: "Country", numeric: false, phone: false },
{ key: "seats", label: "Seats", numeric: true, phone: true },
{ key: "mrr", label: "MRR", numeric: true, phone: true },
] as const
type Sort = { key: keyof Account; desc: boolean }
// Each title is a button, and its header cell carries aria-sort, so a screen
// reader hears the order where it reads the column. A figure sorts highest
// first on its first press, a word A to Z; a second press turns it round.
// The caption says the order in words, for everyone.
export default function TableSortable() {
const [sort, setSort] = React.useState<Sort>({ key: "mrr", desc: true })
const column = COLUMNS.find((entry) => entry.key === sort.key)!
const rows = [...ACCOUNTS].sort((a, b) => {
const [x, y] = [a[sort.key], b[sort.key]]
const order = typeof x === "number" ? x - (y as number) : x.localeCompare(y as string)
return sort.desc ? -order : order
})
const direction = column.numeric ? (sort.desc ? "highest first" : "lowest first") : sort.desc ? "Z to A" : "A to Z"
return (
<Table className="w-full max-w-xl">
<TableCaption>
Enterprise accounts by {column.label}, {direction}.
</TableCaption>
<TableHeader>
<TableRow>
{COLUMNS.map(({ key, label, numeric, phone }) => {
const active = sort.key === key
const Icon = active ? (sort.desc ? ArrowDownIcon : ArrowUpIcon) : ChevronsUpDownIcon
return (
<TableHead
key={key}
aria-sort={active ? (sort.desc ? "descending" : "ascending") : "none"}
className={cn(numeric && "text-right", !phone && "max-sm:hidden")}
>
<button
type="button"
onClick={() => setSort(active ? { key, desc: !sort.desc } : { key, desc: numeric })}
className={cn(
"inline-flex items-center gap-1 rounded-sm focus-ring hover:text-foreground",
active && "text-foreground",
numeric && "flex-row-reverse"
)}
>
{label}
<Icon aria-hidden="true" className="size-3.5 opacity-70" />
</button>
</TableHead>
)
})}
</TableRow>
</TableHeader>
<TableBody>
{rows.map((account) => (
<TableRow key={account.company}>
<TableCell className="font-medium">{account.company}</TableCell>
<TableCell className="text-muted-foreground max-sm:hidden">{account.country}</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(account.seats)}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(account.mrr, "USD", { maximumFractionDigits: 0 })}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Selectable rows
A named checkbox per row and one for them all, a selected plane on each ticked row, and the count in a status line.
"use client"
import * as React from "react"
import { BellRingIcon } from "lucide-react"
import { formatCurrency } from "@/lib/format"
import { REFERENCE_DATE } from "@/lib/sample-data/generate"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const OVERDUE = [
{ number: "INV-100061", company: "Cobalt Logistics", due: "2025-10-27", amount: 912 },
{ number: "INV-100005", company: "Quarry Systems", due: "2025-11-29", amount: 452.93 },
{ number: "INV-100012", company: "Blue Harbor Analytics", due: "2026-01-04", amount: 84 },
{ number: "INV-100015", company: "Brightline Digital", due: "2026-01-17", amount: 264 },
{ number: "INV-100010", company: "Alder Health", due: "2026-03-27", amount: 1200 },
]
const daysLate = (due: string) => Math.floor((REFERENCE_DATE.getTime() - Date.parse(due)) / 86_400_000)
const list = new Intl.ListFormat("en", { type: "conjunction" })
// Every box is a real checkbox named for its invoice, so Tab reaches each one
// and Space ticks it; the header box ticks them all and shows a dash while only
// some are. A ticked row takes the selected plane. The line under the table is
// a status region, so the count is heard as it changes, and it is what the
// send button points at when there is nothing to send.
export default function TableSelectable() {
const statusId = React.useId()
const [ticked, setTicked] = React.useState<string[]>(["INV-100061", "INV-100005"])
const [sent, setSent] = React.useState("")
const count = ticked.length
const all = count === OVERDUE.length
const total = OVERDUE.filter((row) => ticked.includes(row.number)).reduce((sum, row) => sum + row.amount, 0)
const toggle = (number: string, on: boolean) => {
setSent("")
setTicked((current) => (on ? [...current, number] : current.filter((entry) => entry !== number)))
}
return (
<div className="flex w-full max-w-xl flex-col gap-3">
<Table aria-label="Overdue invoices">
<TableHeader>
<TableRow>
<TableHead className="w-8">
<Checkbox
checked={all}
indeterminate={count > 0 && !all}
onCheckedChange={(on) => setTicked(on ? OVERDUE.map((row) => row.number) : [])}
aria-label="Select every overdue invoice"
/>
</TableHead>
<TableHead className="max-sm:hidden">Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right max-sm:hidden">Days late</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{OVERDUE.map((row) => (
<TableRow key={row.number} data-state={ticked.includes(row.number) ? "selected" : undefined}>
<TableCell>
<Checkbox
checked={ticked.includes(row.number)}
onCheckedChange={(on) => toggle(row.number, on)}
aria-label={`Select ${row.number}`}
/>
</TableCell>
<TableCell className="font-mono text-xs max-sm:hidden">{row.number}</TableCell>
<TableCell>{row.company}</TableCell>
<TableCell className="text-right tabular-nums max-sm:hidden">{daysLate(row.due)}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(row.amount)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex flex-wrap items-center justify-between gap-2">
<p id={statusId} role="status" className="text-sm text-muted-foreground tabular-nums">
{sent || (count ? `${count} selected · ${formatCurrency(total)} outstanding` : "Tick the invoices to remind.")}
</p>
<Button
size="sm"
disabled={count === 0}
focusableWhenDisabled
aria-describedby={count === 0 ? statusId : undefined}
onClick={() => {
setSent(`Reminders queued for ${list.format(ticked)}.`)
setTicked([])
}}
>
<BellRingIcon data-icon="inline-start" aria-hidden="true" />
{count ? `Send ${count} ${count === 1 ? "reminder" : "reminders"}` : "Send reminders"}
</Button>
</div>
</div>
)
}Rows that open
The order number is one link stretched over its row: a click anywhere opens it, the keyboard meets one link per order.
import { formatCurrency, formatDateTime } from "@/lib/format"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const ORDERS = [
{ id: "ord_0384", number: "ORD-100384", customer: "Yuki Bauer", placed: "2026-09-04T12:44:00Z", items: 2, total: 558, status: "fulfilled" },
{ id: "ord_0270", number: "ORD-100270", customer: "Rafael Weiss", placed: "2026-09-04T04:53:00Z", items: 1, total: 5, status: "fulfilled" },
{ id: "ord_0149", number: "ORD-100149", customer: "Lena Silva", placed: "2026-09-03T21:26:00Z", items: 4, total: 706, status: "fulfilled" },
{ id: "ord_0086", number: "ORD-100086", customer: "Luis Andersen", placed: "2026-09-03T13:54:00Z", items: 6, total: 794, status: "cancelled" },
{ id: "ord_0219", number: "ORD-100219", customer: "Lena Castillo", placed: "2026-09-03T01:08:00Z", items: 5, total: 176.98, status: "paid" },
]
const STATUS = { fulfilled: "success", paid: "info" } as const
// The order number is the row's one link, and it stretches over the whole row:
// a pointer can click anywhere on it, while a keyboard or a screen reader meets
// one link per order, named by its number, instead of a row pretending to be a
// button. The focus ring is drawn round the row the link covers.
export default function TableLinks() {
return (
<Table aria-label="Latest orders" className="w-full max-w-2xl">
<TableHeader>
<TableRow>
<TableHead>Order</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="max-sm:hidden">Placed</TableHead>
<TableHead className="text-right max-sm:hidden">Items</TableHead>
<TableHead className="text-right max-sm:hidden">Total</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ORDERS.map((order) => (
<TableRow key={order.id}>
<TableCell>
<a
href={`/ecommerce/orders/${order.id}`}
className="font-mono text-xs font-medium underline-offset-4 outline-none after:absolute after:inset-0 after:rounded-sm hover:underline focus-visible:after:focus-outline-inset"
>
{order.number}
</a>
</TableCell>
<TableCell>{order.customer}</TableCell>
<TableCell className="text-muted-foreground max-sm:hidden">
{formatDateTime(order.placed, { timeZone: "UTC", dateStyle: "medium" })}
</TableCell>
<TableCell className="text-right tabular-nums max-sm:hidden">{order.items}</TableCell>
<TableCell className="text-right tabular-nums max-sm:hidden">{formatCurrency(order.total)}</TableCell>
<TableCell>
<StatusBadge status={order.status} map={STATUS} size="sm" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
}Row actions
A menu at the end of each row, named for the row it acts on; a reminder that cannot go says why, and voiding asks first.
"use client"
import * as React from "react"
import { BanIcon, BellRingIcon, EllipsisIcon, EyeIcon } from "lucide-react"
import { formatCurrency, formatDate } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { ConfirmDialog } from "@/components/ui/confirm-dialog"
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const INVOICES = [
{ number: "INV-100008", company: "Northwind Studio", due: "2026-09-05", amount: 168, draft: false },
{ number: "INV-100155", company: "Silverpine Energy", due: "2026-09-07", amount: 672, draft: true },
{ number: "INV-100044", company: "Alder Robotics", due: "2026-09-13", amount: 864, draft: false },
{ number: "INV-100018", company: "Fathom Media", due: "2026-09-14", amount: 776.85, draft: false },
]
type Invoice = (typeof INVOICES)[number]
// The last column holds each row's menu, its button named for the invoice it
// acts on, under a header named for screen readers only. The row keeps its
// hover plane while the menu is open, so it is plain which invoice the menu
// belongs to. A reminder that cannot go stays in the menu, dimmed, saying why;
// voiding asks first.
export default function TableActions() {
const [voided, setVoided] = React.useState<string[]>([])
const [voiding, setVoiding] = React.useState<Invoice | null>(null)
const [done, setDone] = React.useState("")
return (
<div className="flex w-full max-w-2xl flex-col gap-3">
<Table aria-label="Invoices due this month">
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="max-sm:hidden">Due</TableHead>
<TableHead className="text-right">Amount</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-10">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((invoice) => {
const status = voided.includes(invoice.number) ? "void" : invoice.draft ? "draft" : "open"
const held = status === "void" ? "A void invoice is closed" : status === "draft" ? "A draft has not been sent yet" : ""
return (
<TableRow key={invoice.number}>
<TableCell className="font-mono text-xs">{invoice.number}</TableCell>
<TableCell>{invoice.company}</TableCell>
<TableCell className="text-muted-foreground max-sm:hidden">{formatDate(invoice.due, "medium", { timeZone: "UTC" })}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(invoice.amount)}</TableCell>
<TableCell>
<StatusBadge status={status} size="sm" />
</TableCell>
<TableCell className="text-right">
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-sm" aria-label={`Actions for ${invoice.number}`} />}>
<EllipsisIcon aria-hidden="true" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60">
<DropdownMenuItem render={<a href={`/finance/invoices?invoice=${invoice.number}`} />}>
<EyeIcon aria-hidden="true" />
View invoice
</DropdownMenuItem>
<DropdownMenuItem disabled={Boolean(held)} className="items-start" onClick={() => setDone(`Reminder sent to ${invoice.company}.`)}>
<BellRingIcon aria-hidden="true" className="mt-0.5" />
<span className="flex flex-col">
Send a reminder
{held ? <span className="text-xs text-muted-foreground">{held}</span> : null}
</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive" disabled={status === "void"} onClick={() => setVoiding(invoice)}>
<BanIcon aria-hidden="true" />
Void invoice…
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
<p role="status" className="min-h-5 text-sm text-muted-foreground">
{done}
</p>
<ConfirmDialog
variant="destructive"
open={voiding !== null}
onOpenChange={(open) => !open && setVoiding(null)}
title={`Void ${voiding?.number ?? "invoice"}?`}
description={voiding ? `${voiding.company} will no longer owe ${formatCurrency(voiding.amount)}.` : undefined}
confirmText="Void invoice"
onConfirm={() => {
if (!voiding) return
setVoided((current) => [...current, voiding.number])
setDone(`${voiding.number} is void.`)
}}
/>
</div>
)
}Expandable rows
A disclosure button per order opens its lines in a row beneath, which stays in the table, hidden, while shut.
"use client"
import * as React from "react"
import { ChevronRightIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency } from "@/lib/format"
import { Button } from "@/components/ui/button"
import { ProductArt } from "@/components/ui/product-art"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type Line = [product: string, qty: number, unit: number]
const ORDERS: { number: string; customer: string; lines: Line[] }[] = [
{ number: "ORD-100149", customer: "Lena Silva", lines: [["Corner Desk", 2, 249], ["Hiking Backpack 40L", 1, 149], ["Paper Shredder", 1, 59]] },
{ number: "ORD-100037", customer: "Anouk Silva", lines: [["Bookshelf 5-Tier", 2, 149], ["Laptop Stand", 1, 34], ["Non-Stick Baking Sheet", 2, 17.99]] },
{ number: "ORD-100219", customer: "Lena Castillo", lines: [["Webcam HD", 1, 59], ["Wireless Mouse", 2, 29], ["Aroma Diffuser", 2, 29.99]] },
]
// Each order opens in place. The chevron is a button that says whose lines it
// shows and whether they are showing (aria-expanded), and it points at the
// row it opens (aria-controls), which stays in the table, hidden, while shut.
export default function TableExpandable() {
const [open, setOpen] = React.useState<string[]>(["ORD-100149"])
const baseId = React.useId()
return (
<Table aria-label="Orders from September 3" className="w-full max-w-xl">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<span className="sr-only">Lines</span>
</TableHead>
<TableHead>Order</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right">Total</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ORDERS.map((order) => {
const expanded = open.includes(order.number)
const detailId = `${baseId}-${order.number}`
const total = order.lines.reduce((sum, [, qty, unit]) => sum + qty * unit, 0)
return (
<React.Fragment key={order.number}>
<TableRow className={cn(expanded && "border-b-0")}>
<TableCell>
<Button
variant="ghost"
size="icon-sm"
aria-expanded={expanded}
aria-controls={detailId}
aria-label={`Lines of ${order.number}`}
onClick={() => setOpen((current) => (expanded ? current.filter((n) => n !== order.number) : [...current, order.number]))}
>
<ChevronRightIcon aria-hidden="true" className={cn("transition-transform duration-(--duration-fast) rtl:rotate-180", expanded && "rotate-90 rtl:rotate-90")} />
</Button>
</TableCell>
<TableCell className="font-mono text-xs">{order.number}</TableCell>
<TableCell>{order.customer}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(total)}</TableCell>
</TableRow>
<TableRow id={detailId} hidden={!expanded} className="bg-surface hover:bg-surface">
<TableCell colSpan={4} className="px-4 py-3">
<ul aria-label={`Lines of ${order.number}`} className="flex flex-col gap-2">
{order.lines.map(([product, qty, unit]) => (
<li key={product} className="flex items-center gap-3 text-sm">
<ProductArt name={product} className="size-8 shrink-0 rounded-md" />
<span className="min-w-0 flex-1 truncate">{product}</span>
<span className="text-muted-foreground tabular-nums max-sm:hidden">
{qty} × {formatCurrency(unit)}
</span>
<span className="w-20 text-right tabular-nums">{formatCurrency(qty * unit)}</span>
</li>
))}
</ul>
</TableCell>
</TableRow>
</React.Fragment>
)
})}
</TableBody>
</Table>
)
}Grouped rows
One body per group under a rowgroup header, with the group's subtotal in the amount column.
import { formatCurrency, formatDate } from "@/lib/format"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type Invoice = [number: string, company: string, due: string, amount: number]
const GROUPS: { title: string; invoices: Invoice[] }[] = [
{
title: "Overdue",
invoices: [
["INV-100061", "Cobalt Logistics", "2025-10-27", 912],
["INV-100005", "Quarry Systems", "2025-11-29", 452.93],
["INV-100012", "Blue Harbor Analytics", "2026-01-04", 84],
],
},
{
title: "Due this week",
invoices: [
["INV-100008", "Northwind Studio", "2026-09-05", 168],
["INV-100055", "Fathom Partners", "2026-09-05", 1272],
],
},
{
title: "Due later",
invoices: [
["INV-100044", "Alder Robotics", "2026-09-13", 864],
["INV-100018", "Fathom Media", "2026-09-14", 776.85],
],
},
]
// One body per group, headed by a row whose first cell is a header for the
// whole group (scope="rowgroup"): a screen reader names the group with each
// invoice in it, and the group's own subtotal sits in the amount column.
export default function TableGrouped() {
return (
<Table aria-label="Receivables by due date" className="w-full max-w-xl">
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
{GROUPS.map((group) => (
<TableBody key={group.title} className="border-t border-rule first-of-type:border-t-0">
<TableRow className="bg-surface hover:bg-surface">
<TableHead scope="rowgroup" colSpan={2} className="h-9 text-foreground">
{group.title}{" "}
<span className="ms-1 font-normal text-muted-foreground tabular-nums">
{group.invoices.length} {group.invoices.length === 1 ? "invoice" : "invoices"}
</span>
</TableHead>
<TableCell className="text-right font-medium tabular-nums">
{formatCurrency(group.invoices.reduce((sum, [, , , amount]) => sum + amount, 0))}
</TableCell>
</TableRow>
{group.invoices.map(([number, company, due, amount]) => (
<TableRow key={number}>
<TableCell className="font-mono text-xs">{number}</TableCell>
<TableCell>
{company}
<span className="block text-xs text-muted-foreground">Due {formatDate(due, "medium", { timeZone: "UTC" })}</span>
</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(amount)}</TableCell>
</TableRow>
))}
</TableBody>
))}
</Table>
)
}Scrolls both ways
A long, wide list in a keyboard-scrollable box: the titles stay on top and each SKU stays at the left, both opaque.
import * as React from "react"
import { TriangleAlertIcon } from "lucide-react"
import { cn } from "@/lib/utils"
import { formatCurrency, formatNumber } from "@/lib/format"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
type Product = [sku: string, name: string, category: string, price: number, stock: number, reorder: number, supplier: string]
const STOCK: Product[] = [
["OFFC-0005", "Ergonomic Office Chair", "Office", 179, 23, 25, "Meridian Trading"],
["OUTD-0018", "Folding Camp Chair", "Outdoor", 39, 16, 54, "Thistledown Trading"],
["HOME-0001", "Throw Blanket", "Home", 34.99, 316, 18, "Cobalt Sourcing"],
["FURN-0002", "Accent Armchair", "Furniture", 329, 317, 29, "Northstar Distribution"],
["ELEC-0003", "Mechanical Keyboard", "Electronics", 89, 345, 26, "Amberlane Goods"],
["ELEC-0004", "Webcam HD", "Electronics", 59, 257, 29, "Harborline Wholesale"],
["FURN-0006", "Console Table", "Furniture", 199, 86, 14, "Cobalt Sourcing"],
["HOME-0007", "Cotton Bath Towel Set", "Home", 39.99, 375, 48, "Crestpoint Supply Co."],
["HOME-0009", "Ceramic Mug Set", "Home", 24.99, 147, 16, "Meridian Trading"],
["APRL-0010", "Running Shoes", "Apparel", 79, 60, 34, "Ashgrove Imports"],
]
const COLUMNS = [
{ title: "Product" },
{ title: "Category" },
{ title: "Price", numeric: true },
{ title: "Stock", numeric: true },
{ title: "Reorder at", numeric: true },
{ title: "Supplier" },
]
// Titles stick to the top of the box and each SKU to its left edge. Pinned
// cells are opaque — the card, or the row's hover plane mixed into it — so the
// figures scrolling under them never show through.
const TOP = "sticky top-0 z-10 bg-surface shadow-[inset_0_-1px_0_var(--rule)]"
const LEFT =
"sticky left-0 z-10 bg-card shadow-[inset_-1px_0_0_var(--border)] group-hover:bg-[color-mix(in_srgb,var(--muted)_50%,var(--card))]"
// A long, wide stock list in a box of its own that scrolls both ways, so every
// figure keeps its column title and its SKU in view. The box is a region in the
// tab order, named by the table's caption, so the arrow keys scroll it as well
// as a wheel does.
export default function TableScroll() {
const captionId = React.useId()
return (
<div
role="region"
aria-labelledby={captionId}
tabIndex={0}
className="panel max-h-72 w-full max-w-2xl overflow-auto focus-ring [&_[data-slot=table-container]]:overflow-visible"
>
<Table>
<TableCaption id={captionId} className="sr-only">
Stock levels
</TableCaption>
<TableHeader>
<TableRow>
<TableHead className={cn(TOP, "left-0 z-20 shadow-[inset_-1px_0_0_var(--border),inset_0_-1px_0_var(--rule)]")}>SKU</TableHead>
{COLUMNS.map((column) => (
<TableHead key={column.title} className={cn(TOP, column.numeric && "text-right")}>
{column.title}
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{STOCK.map(([sku, name, category, price, stock, reorder, supplier]) => (
<TableRow key={sku} className="group">
<TableCell className={cn(LEFT, "font-mono text-xs")}>{sku}</TableCell>
<TableCell className="font-medium">{name}</TableCell>
<TableCell className="text-muted-foreground">{category}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(price)}</TableCell>
<TableCell className={cn("text-right tabular-nums", stock < reorder && "font-medium text-danger")}>
{stock < reorder ? <TriangleAlertIcon aria-hidden="true" className="me-1 inline size-3.5 align-[-2px]" /> : null}
{formatNumber(stock)}
{stock < reorder ? <span className="sr-only">, below its reorder point</span> : null}
</TableCell>
<TableCell className="text-right tabular-nums">{formatNumber(reorder)}</TableCell>
<TableCell className="text-muted-foreground">{supplier}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Empty
The columns stay and one row across them says why nothing is here and what fills it.
import { LandmarkIcon, PlusIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
// The columns stay, so the reader sees what will fill the table; the one row
// under them spans every column and says why it is empty and what fills it.
// It is not a hover target, since there is nothing in it to pick.
export default function TableEmpty() {
return (
<Table aria-label="Payouts" className="w-full max-w-xl">
<TableHeader>
<TableRow>
<TableHead>Payout</TableHead>
<TableHead>Arrives</TableHead>
<TableHead>Account</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="p-0 whitespace-normal">
<EmptyState
size="sm"
icon={<LandmarkIcon />}
title="No payouts yet"
description="Northwind pays out two days after an invoice is paid. Add the account the money goes to, and the first payout lands here."
action={
<Button size="sm">
<PlusIcon data-icon="inline-start" aria-hidden="true" />
Add a bank account
</Button>
}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
)
}Right to left
An Arabic table: titles from the right, figures on the inline end, and the selected rail on the right.
import { formatCurrency } from "@/lib/format"
import { StatusBadge } from "@/components/ui/status-badge"
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"
const INVOICES = [
{ number: "INV-100044", company: "Alder Robotics", status: "open", label: "مفتوحة", amount: 864 },
{ number: "INV-100043", company: "Harborview Studio", status: "overdue", label: "متأخرة", amount: 10560 },
{ number: "INV-100120", company: "Lakeside Analytics", status: "paid", label: "مدفوعة", amount: 480 },
]
// The table in Arabic. Column titles start at the right, the figures line up on
// the inline end, and the selected row's rail sits on its first cell's outer
// edge — the right — as it sits on the left of a left-to-right table. An id
// and an amount are isolated (bdi), so each keeps its own left-to-right order.
export default function TableRtl() {
return (
<div dir="rtl" lang="ar" className="w-full max-w-xl">
<Table>
<TableCaption>فواتير سبتمبر لشركة نورثويند</TableCaption>
<TableHeader>
<TableRow>
<TableHead>الفاتورة</TableHead>
<TableHead>العميل</TableHead>
<TableHead>الحالة</TableHead>
<TableHead className="text-end">المبلغ</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((invoice) => (
<TableRow key={invoice.number} data-state={invoice.status === "overdue" ? "selected" : undefined}>
<TableCell className="font-mono text-xs">
<bdi>{invoice.number}</bdi>
</TableCell>
<TableCell>{invoice.company}</TableCell>
<TableCell>
<StatusBadge status={invoice.status} label={invoice.label} size="sm" />
</TableCell>
<TableCell className="text-end tabular-nums">
<bdi>{formatCurrency(invoice.amount)}</bdi>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| TableRow data-state="selected" | attribute | — | Marks the row selected: the accent-muted fill, the rail and the weight. |
| TableHead aria-sort | "ascending" | "descending" | "none" | — | On a sortable column's title, so a screen reader hears the order where it reads the column; put the sort button inside the cell. |
| TableHead scope="rowgroup" | attribute | — | Heads a group of rows in its own TableBody; each row in the group is read with the group's name. |
Dependencies
Registry
Source
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
/** Whether a box scrolls: its overflow lets it, and what it holds is bigger than it is. */
function scrolls(box: HTMLElement) {
const style = getComputedStyle(box)
const scrollable = (overflow: string) => overflow === "auto" || overflow === "scroll"
return (
(scrollable(style.overflowX) && box.scrollWidth > box.clientWidth) ||
(scrollable(style.overflowY) && box.scrollHeight > box.clientHeight)
)
}
/** The text a name comes from: the elements aria-labelledby names, else aria-label. */
function nameOf(element: Element | null | undefined): string {
if (!element) return ""
const ids = element.getAttribute("aria-labelledby")
if (ids) {
return ids
.split(/\s+/)
.map((id) => element.ownerDocument.getElementById(id)?.textContent?.trim() ?? "")
.join(" ")
.trim()
}
return element.getAttribute("aria-label")?.trim() ?? ""
}
/**
* Whether the box, as a region, would carry the very name of the landmark
* region around it — a card or a band named by the same title as its table —
* which is two landmarks of one name (axe landmark-unique, on fourteen pages
* and eight table sections). The box is then a group instead: still a named
* tab stop, no second landmark.
*/
function sharesEnclosingName(box: HTMLElement, table: HTMLTableElement | null): boolean {
const own = nameOf(table) || table?.caption?.textContent?.trim() || ""
const around = box.parentElement?.closest('[role="region"], section[aria-label], section[aria-labelledby]')
return Boolean(own && around && nameOf(around) === own)
}
/** The id a TableCaption takes when it is given none, so the scroll region can be named by it. */
const CaptionIdContext = React.createContext<string | undefined>(undefined)
/**
* The table, in a box that scrolls it sideways when it is wider than the page.
* A box that scrolls is one the keyboard has to reach — the arrow keys scroll
* only what has the focus — so while the table overflows, the box is a tab
* stop and a region named after the table (its aria-labelledby, its
* aria-label, else its caption), with a focus ring drawn inside its edge where
* no ancestor's overflow can clip it — or a group of that name, when the card
* or band around it is already a region called the same. While it fits, it is
* neither: a tab stop that scrolls nothing is one more press for nothing.
*/
function Table({
className,
"aria-label": ariaLabel,
"aria-labelledby": ariaLabelledBy,
...props
}: React.ComponentProps<"table">) {
const boxRef = React.useRef<HTMLDivElement>(null)
const captionId = React.useId()
const [scrolling, setScrolling] = React.useState(false)
const [captionedBy, setCaptionedBy] = React.useState<string | undefined>(undefined)
const [sharesName, setSharesName] = React.useState(false)
React.useEffect(() => {
const box = boxRef.current
if (!box) return
// Measured after mount and on every resize, of the box or of the table in
// it: a page of wider rows, a column shown, a narrower window.
const measure = () => {
const table = box.firstElementChild as HTMLTableElement | null
setScrolling(scrolls(box))
setCaptionedBy(table?.caption?.id || undefined)
setSharesName(sharesEnclosingName(box, table))
}
measure()
if (typeof ResizeObserver === "undefined") return
const observer = new ResizeObserver(measure)
observer.observe(box)
if (box.firstElementChild) observer.observe(box.firstElementChild)
return () => observer.disconnect()
}, [])
const regionLabelledBy = ariaLabelledBy ?? (ariaLabel ? undefined : captionedBy)
const named = Boolean(regionLabelledBy || ariaLabel)
return (
<CaptionIdContext.Provider value={captionId}>
<div
ref={boxRef}
data-slot="table-container"
data-scrolling={scrolling || undefined}
{...(scrolling
? {
tabIndex: 0,
...(named
? {
role: sharesName ? "group" : "region",
"aria-labelledby": regionLabelledBy,
"aria-label": regionLabelledBy ? undefined : ariaLabel,
}
: null),
}
: null)}
className="relative w-full overflow-x-auto focus-ring-inset"
>
<table
data-slot="table"
aria-label={ariaLabel}
aria-labelledby={ariaLabelledBy}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
</CaptionIdContext.Provider>
)
}
function TableHeader({ className, ...props }: React.ComponentProps<"thead">) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b [&_tr]:border-rule", className)}
{...props}
/>
)
}
function TableBody({ className, ...props }: React.ComponentProps<"tbody">) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
)
}
function TableFooter({ className, ...props }: React.ComponentProps<"tfoot">) {
return (
<tfoot
data-slot="table-footer"
className={cn(
"border-t border-rule bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
)
}
// A selected row is the accent-muted plane, medium weight and a 2px accent
// rail. The rail is an inset shadow on the first cell: drawn as a ::before on
// the row, Chrome laid it out as one more cell and pushed every cell of a
// selected row a column to the right. A shadow has no logical offset, so a
// right-to-left row turns it round: the rail stays on the row's inline start.
function TableRow({ className, ...props }: React.ComponentProps<"tr">) {
return (
<tr
data-slot="table-row"
className={cn(
"relative border-b transition-colors duration-(--duration-fast) ease-(--ease-standard) hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-brand-muted data-[state=selected]:font-medium data-[state=selected]:*:first:shadow-[inset_2px_0_0_var(--brand)] rtl:data-[state=selected]:*:first:shadow-[inset_-2px_0_0_var(--brand)]",
className
)}
{...props}
/>
)
}
// Titles start at the inline start and a checkbox column trims its inline
// end, so a right-to-left table reads from the right like its rows do.
function TableHead({ className, ...props }: React.ComponentProps<"th">) {
return (
<th
data-slot="table-head"
className={cn(
"type-label h-10 px-2 text-start align-middle whitespace-nowrap text-muted-foreground [&:has([role=checkbox])]:pe-0",
className
)}
{...props}
/>
)
}
function TableCell({ className, ...props }: React.ComponentProps<"td">) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pe-0",
className
)}
{...props}
/>
)
}
function TableCaption({
className,
id,
...props
}: React.ComponentProps<"caption">) {
const fallbackId = React.useContext(CaptionIdContext)
return (
<caption
id={id ?? fallbackId}
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}