Table
Semantic HTML tables for structured data, with tabular numerals, row states and the parts to build sortable, selectable data tables.
import { Badge, BadgeDot } from "@/components/ui/badge"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const deployments = [
{ id: "#1284", status: "Building", time: "31s" },
{ id: "#1283", status: "Ready", time: "48s" },
{ id: "#1282", status: "Error", time: "12s" },
] as const
const variants = {
Building: "warning",
Ready: "success",
Error: "destructive",
} as const
export default function TableDemo() {
return (
<div className="w-full max-w-sm rounded-xl border bg-card shadow-raised">
<Table aria-label="Recent deployments">
<TableHeader>
<TableRow>
<TableHead scope="col">Deploy</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead scope="col" className="text-right">
Time
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{deployments.map((deployment) => (
<TableRow key={deployment.id}>
<TableCell className="font-medium">{deployment.id}</TableCell>
<TableCell>
<Badge variant={variants[deployment.status]}>
<BadgeDot />
{deployment.status}
</Badge>
</TableCell>
<TableCell className="text-right">{deployment.time}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Anatomy
- 1ContainerScrolls the table sideways when it's wider than the page, instead of squeezing the columns.
- 2CaptionNames the table for screen readers. Show it below the table, or hide it visually.
- 3Header cellA column label in small, muted text. Sortable columns put a button here and set aria-sort.
- 4Numeric cellNumbers align right in tabular figures, so digits line up down the column.
- 5Row48px tall in the body, divided by hairlines. Body rows tint on hover, and with the brand color when selected.
Installation
$ pnpm dlx shadcn@latest add https://www.prfct.dev/r/table.jsonUsage
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"<Table>
<TableCaption>Usage for September 2026.</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">Resource</TableHead>
<TableHead scope="col" className="text-right">Cost</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>Compute</TableCell>
<TableCell className="text-right">$186.20</TableCell>
</TableRow>
</TableBody>
</Table>Table wraps the <table> in a horizontally scrolling container, so wide tables never break the page layout on small screens. Numbers use tabular figures by default, so digits line up column by column.
Examples
Caption and footer
A caption names the table for everyone — it's the first thing a screen reader announces. TableFooter holds totals on a tinted row.
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const usage = [
{
resource: "Compute",
unit: "GB-hours",
quantity: "12,480",
cost: "$186.20",
},
{ resource: "Bandwidth", unit: "GB", quantity: "3,902", cost: "$58.53" },
{ resource: "Storage", unit: "GB-months", quantity: "812", cost: "$16.24" },
{
resource: "Build minutes",
unit: "minutes",
quantity: "6,140",
cost: "$30.70",
},
]
export default function TableBasic() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card shadow-raised">
<Table>
<TableCaption className="mb-4">
Usage for September 2026. Prices exclude tax.
</TableCaption>
<TableHeader>
<TableRow>
<TableHead scope="col">Resource</TableHead>
<TableHead scope="col">Unit</TableHead>
<TableHead scope="col" className="text-right">
Quantity
</TableHead>
<TableHead scope="col" className="text-right">
Cost
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usage.map((row) => (
<TableRow key={row.resource}>
<TableCell className="font-medium">{row.resource}</TableCell>
<TableCell className="text-muted-foreground">
{row.unit}
</TableCell>
<TableCell className="text-right">{row.quantity}</TableCell>
<TableCell className="text-right">{row.cost}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={3}>Total</TableCell>
<TableCell className="text-right">$291.67</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
)
}Data table
Selection, sorting and row actions are composition, not configuration: a checkbox column, header buttons that cycle the sort, and a menu per row. The header checkbox shows an indeterminate state when some — but not all — rows are selected, and selected rows take the brand tint.
1 of 6 selected
"use client"
import * as React from "react"
import {
ArrowDownIcon,
ArrowUpDownIcon,
ArrowUpIcon,
MoreHorizontalIcon,
} from "lucide-react"
import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
type Status = "Paid" | "Pending" | "Failed" | "Refunded"
type Payment = {
id: string
customer: string
email: string
status: Status
amount: number
}
const payments: Payment[] = [
{
id: "pay_8K2",
customer: "Northwind Traders",
email: "billing@northwind.dev",
status: "Paid",
amount: 4820,
},
{
id: "pay_8K3",
customer: "Lumen Studio",
email: "finance@lumen.studio",
status: "Pending",
amount: 1260,
},
{
id: "pay_8K4",
customer: "Atlas Robotics",
email: "ap@atlasrobotics.io",
status: "Failed",
amount: 312.5,
},
{
id: "pay_8K5",
customer: "Kestrel Health",
email: "accounts@kestrel.health",
status: "Paid",
amount: 9750,
},
{
id: "pay_8K6",
customer: "Oakline Bank",
email: "treasury@oakline.com",
status: "Refunded",
amount: 640,
},
{
id: "pay_8K7",
customer: "Pinecone Labs",
email: "billing@pinecone.dev",
status: "Paid",
amount: 2190.75,
},
]
const statusVariant = {
Paid: "success",
Pending: "warning",
Failed: "destructive",
Refunded: "secondary",
} as const
type SortKey = "customer" | "amount"
type Sort = { key: SortKey; direction: "ascending" | "descending" }
const currency = new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
})
function SortButton({
label,
column,
sort,
onSort,
align = "start",
}: {
label: string
column: SortKey
sort: Sort | null
onSort: (key: SortKey) => void
align?: "start" | "end"
}) {
const active = sort?.key === column
const Icon = !active
? ArrowUpDownIcon
: sort.direction === "ascending"
? ArrowUpIcon
: ArrowDownIcon
return (
<Button
variant="ghost"
size="xs"
onClick={() => onSort(column)}
className={align === "end" ? "-mr-2.5 ml-auto" : "-ml-2.5"}
>
{label}
<Icon
data-icon="inline-end"
className={active ? "text-foreground" : "text-muted-foreground"}
/>
</Button>
)
}
export default function TableDataTable() {
const [selected, setSelected] = React.useState<Set<string>>(
new Set(["pay_8K3"])
)
const [sort, setSort] = React.useState<Sort | null>({
key: "amount",
direction: "descending",
})
const rows = React.useMemo(() => {
if (!sort) return payments
const sorted = [...payments].sort((a, b) =>
sort.key === "amount"
? a.amount - b.amount
: a.customer.localeCompare(b.customer)
)
return sort.direction === "ascending" ? sorted : sorted.reverse()
}, [sort])
const allSelected = selected.size === payments.length
const someSelected = selected.size > 0 && !allSelected
function toggleSort(key: SortKey) {
setSort((current) =>
current?.key !== key
? { key, direction: "ascending" }
: current.direction === "ascending"
? { key, direction: "descending" }
: null
)
}
function toggleRow(id: string, checked: boolean) {
setSelected((current) => {
const next = new Set(current)
if (checked) next.add(id)
else next.delete(id)
return next
})
}
const ariaSort = (key: SortKey) =>
sort?.key === key ? sort.direction : ("none" as const)
return (
<div className="flex w-full max-w-3xl flex-col gap-3">
<div className="flex h-8 items-center justify-between">
<p className="text-sm text-muted-foreground" aria-live="polite">
{selected.size > 0
? `${selected.size} of ${payments.length} selected`
: `${payments.length} payments`}
</p>
{selected.size > 0 && (
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={() => setSelected(new Set())}
>
Clear
</Button>
<Button size="sm" variant="outline">
Export {selected.size}
</Button>
</div>
)}
</div>
<div className="rounded-xl border bg-card shadow-raised">
<Table aria-label="Payments">
<TableHeader>
<TableRow>
<TableHead scope="col">
<Checkbox
aria-label="Select all payments"
checked={allSelected}
indeterminate={someSelected}
onCheckedChange={(checked) =>
setSelected(
checked ? new Set(payments.map((p) => p.id)) : new Set()
)
}
/>
</TableHead>
<TableHead scope="col" aria-sort={ariaSort("customer")}>
<SortButton
label="Customer"
column="customer"
sort={sort}
onSort={toggleSort}
/>
</TableHead>
<TableHead scope="col">Status</TableHead>
<TableHead
scope="col"
aria-sort={ariaSort("amount")}
className="text-right"
>
<SortButton
label="Amount"
column="amount"
sort={sort}
onSort={toggleSort}
align="end"
/>
</TableHead>
<TableHead scope="col">
<span className="sr-only">Actions</span>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((payment) => {
const isSelected = selected.has(payment.id)
return (
<TableRow
key={payment.id}
data-state={isSelected ? "selected" : undefined}
>
<TableCell>
<Checkbox
aria-label={`Select payment from ${payment.customer}`}
checked={isSelected}
onCheckedChange={(checked) =>
toggleRow(payment.id, checked)
}
/>
</TableCell>
<TableCell>
<div className="flex flex-col">
<span className="font-medium">{payment.customer}</span>
<span className="text-xs text-muted-foreground">
{payment.email}
</span>
</div>
</TableCell>
<TableCell>
<Badge variant={statusVariant[payment.status]}>
<BadgeDot />
{payment.status}
</Badge>
</TableCell>
<TableCell className="text-right font-medium">
{currency.format(payment.amount)}
</TableCell>
<TableCell className="w-12">
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button
variant="ghost"
size="icon-sm"
aria-label={`Actions for ${payment.customer}`}
/>
}
>
<MoreHorizontalIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44">
<DropdownMenuGroup>
<DropdownMenuItem>View payment</DropdownMenuItem>
<DropdownMenuItem>Copy payment ID</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
Refund
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
</div>
</div>
)
}<TableHead aria-sort={sort?.key === "amount" ? sort.direction : "none"}>
<Button variant="ghost" size="xs" onClick={() => toggleSort("amount")}>
Amount
<ArrowUpDownIcon data-icon="inline-end" />
</Button>
</TableHead>Empty
When a query returns nothing, keep the header so people understand what would be there, and put an Empty state in a single full-width cell with a way out.
import { SearchXIcon } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export default function TableEmpty() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card shadow-raised">
<Table aria-label="Deployments">
<TableHeader>
<TableRow>
<TableHead scope="col">Deployment</TableHead>
<TableHead scope="col">Branch</TableHead>
<TableHead scope="col">Author</TableHead>
<TableHead scope="col" className="text-right">
Duration
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow className="hover:bg-transparent">
<TableCell colSpan={4} className="h-auto p-0">
<Empty className="py-10 md:py-10">
<EmptyHeader>
<EmptyMedia variant="icon">
<SearchXIcon />
</EmptyMedia>
<EmptyTitle>No deployments match “hotfix/*”</EmptyTitle>
<EmptyDescription>
Try another branch or clear the filter to see all 214
deployments.
</EmptyDescription>
</EmptyHeader>
<EmptyContent>
<Button size="sm" variant="outline">
Clear filter
</Button>
</EmptyContent>
</Empty>
</TableCell>
</TableRow>
</TableBody>
</Table>
</div>
)
}Loading
Show skeleton rows in the shape of the real content while data loads, and mark the table aria-busy so assistive technology waits for it.
import { Skeleton } from "@/components/ui/skeleton"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
const widths = ["w-40", "w-32", "w-44", "w-36", "w-28"]
export default function TableLoading() {
return (
<div className="w-full max-w-2xl rounded-xl border bg-card shadow-raised">
<Table aria-label="Customers" aria-busy="true">
<TableHeader>
<TableRow>
<TableHead scope="col">Customer</TableHead>
<TableHead scope="col">Plan</TableHead>
<TableHead scope="col" className="text-right">
MRR
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{widths.map((width, index) => (
<TableRow key={index} className="hover:bg-transparent">
<TableCell>
<div className="flex items-center gap-3">
<Skeleton className="size-8 rounded-full" />
<Skeleton className={`h-3.5 ${width}`} />
</div>
</TableCell>
<TableCell>
<Skeleton className="h-5 w-16 rounded-md" />
</TableCell>
<TableCell>
<Skeleton className="ml-auto h-3.5 w-14" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<span className="sr-only" role="status">
Loading customers…
</span>
</div>
)
}Guidelines
When to use
- To compare many objects across the same set of attributes.
- When people need to scan, sort, filter or act on rows.
When not to use
- For layout. Tables are for data; use grid or flex for arranging things on a page.
- For a handful of objects with rich content — use Cards or a list of Items.
- For a single record's fields — use a description list.
Alignment
- Text left, numbers right. Right-aligned numbers put units, tens and hundreds in the same column — together with tabular figures, magnitudes compare at a glance.
- Align the header with its column's content, including the sort button.
- Keep units in the header (Cost (USD), Duration (s)) rather than repeating them in every cell.
Density
Rows are 48px tall — comfortable for mixed content with badges and actions. For read-only, numbers-only tables, reduce cell height with className="h-10"; don't go below 32px, which is the smallest comfortable pointer target.
Row actions
Put actions for a single row in an overflow menu at the end of the row, and actions for many rows in a toolbar that appears when rows are selected. Don't make the whole row clickable and put buttons inside it.
Accessibility
prfct tables are real <table> elements, so rows, columns and headers are announced without extra ARIA.
| Key | Behavior |
|---|---|
Tab | Moves between interactive elements inside the table: checkboxes, sort buttons, row menus. |
Space | Toggles the focused row checkbox. |
EnterSpace | Activates the focused sort button or opens the row menu. |
- Name the table. Use
TableCaption, oraria-labelwhen a visible heading already names it. - Scope headers. Add
scope="col"to column headers (andscope="row"to row headers) so each cell is announced with its header. - Announce sorting. Set
aria-sorton the sortedTableHead(ascending,descendingornone). The icon alone isn't announced. - Label every control. Row checkboxes and menus need labels that name the row — "Select payment from Northwind Traders", not "Select".
- Selection isn't color. The selected tint is reinforced by the checked checkbox; don't rely on the tint alone.
API reference
Each part renders the matching HTML element and accepts all of its props.