Component · Composite & Advanced
Table
Overview
Presents a set of records as rows with shared, labelled columns, so values line up for scanning and comparison. Use it when the data is genuinely tabular — every row has the same fields, and a reader benefits from columns they can run their eye down (invoices, users, line items, settings with a value per row).
Reach for something else when: each record needs its own layout or free-form content (that’s a list of Cards); there are only two or three key/value pairs (a description list); or the interaction is really a picker (a Select / Dropdown Menu, not a table with one clickable column).
Scope — a semantic table, not a data-grid. This component is a styling and composition
layer over real, semantic HTML: <table> / <caption> / <thead> / <tbody> / <tfoot> /
<tr> / <th> / <td>, nothing swapped for <div>s. It does not own a data model. There
is no column-definitions API, no virtualization, no built-in sorting / filtering / pagination
logic, no editable cells, no column resize / reorder / show-hide, and no role="grid" keyboard
model. Selection and sort are presentation + ARIA affordances only — the caller holds the
selected set and the sort state, and does the actual data work. When a screen needs a managed
grid (tens of thousands of rows, column virtualization, inline editing, frozen columns as a
feature), that is out of scope for Version 1: compose a headless grid library and style its
cells with these same parts.
Anatomy
Table <div overflow-x-auto tabIndex={0}> scroll container (NOT a role="region")
└ <table data-density> aria-busy while `loading`
├─ TableCaption <caption> the table’s accessible name (or aria-label on Table)
├─ TableHeader <thead>
│ └─ TableRow <tr>
│ └─ TableHead <th scope="col"> sortable? → internal <button> + aria-sort
├─ TableBody <tbody>
│ ├─ TableRow <tr> selected? → data-state="selected"
│ │ ├─ TableHead <th scope="row"> optional row-header cell
│ │ └─ TableCell <td align="start|center|end">
│ └─ TableEmpty <tr><td colSpan={n}> empty-state helper
└─ TableFooter <tfoot> optional — totals / summary row
Table— the scroll container plus the<table>.classNamemerges onto the<table>;containerClassNameonto the scroll<div>. The container isoverflow-x: autoand carriestabIndex={0}so a keyboard user can scroll a wide table; it is deliberately not a labelledrole="region"landmark (that belongs to few, major regions — a data table earns its name from its<caption>, not a landmark).density—"comfortable"(default) or"compact". Written asdata-densityon the<table>and read by the cells through agroup-datavariant — it changes cell padding only, nothing structural, and needs no context.loading— setsaria-busy="true"on the<table>while the caller renders<Skeleton>rows in the body (see States).
TableCaption— the<caption>. An accessible name is mandatory: render a<TableCaption>, or passaria-label/aria-labelledbyon<Table>.TableHeader/TableBody/TableFooter— thin<thead>/<tbody>/<tfoot>wrappers.TableFootercarries the divider +--mutedtint for a totals row.TableRow— a<tr>.selectedapplies the row highlight viadata-state="selected"and nothing else — the row’s own<Checkbox>carries the real selection state, so there is noaria-selectedon the<tr>.TableHead— one<th>primitive for both column and row headers, viascope("col"default,"row"for a leading row-header cell). Whensortable, it renders an internal real<button>(nativeEnter/Space, own focus ring) with a directional indicator, and setsaria-sorton the<th>—"ascending"/"descending"whilesortDirectionis"asc"/"desc", and omitted otherwise. It never sorts data;onSortis the caller’s hook.TableCell— a<td>.alignis"start"(default),"center", or"end";"end"is for numeric columns — addtabular-numsinclassNamethere so digits line up.TableEmpty— a small<tr><td>helper for the empty state.colSpanis required (the cell must span every column); there is no auto-detection.
Live Example
Basic — caption, column headers, rows
| Invoice | Customer | Status | Amount |
|---|---|---|---|
| INV-1001 | Acme Corp | Paid | $1,250.00 |
| INV-1002 | Globex | Pending | $890.50 |
| INV-1003 | Initech | Overdue | $4,200.00 |
| INV-1004 | Umbrella | Paid | $320.75 |
Footer — a totals row in the table foot
| Invoice | Customer | Amount |
|---|---|---|
| INV-1001 | Acme Corp | $1,250.00 |
| INV-1002 | Globex | $890.50 |
| INV-1003 | Initech | $4,200.00 |
| INV-1004 | Umbrella | $320.75 |
| Total | $6,661.25 | |
Density — “compact” tightens cell padding
| Invoice | Method | Amount |
|---|---|---|
| INV-1001 | Card | $1,250.00 |
| INV-1002 | Transfer | $890.50 |
| INV-1003 | Card | $4,200.00 |
| INV-1004 | PayPal | $320.75 |
Selectable rows — a leading Checkbox column; the header checkbox is indeterminate on a partial selection (1 selected)
| Invoice | Customer | Amount | |
|---|---|---|---|
| INV-1001 | Acme Corp | $1,250.00 | |
| INV-1002 | Globex | $890.50 | |
| INV-1003 | Initech | $4,200.00 | |
| INV-1004 | Umbrella | $320.75 |
Sortable headers — the component sets aria-sort and the indicator; the caller sorts the data
| Invoice | ||
|---|---|---|
| INV-1003 | Initech | $4,200.00 |
| INV-1001 | Acme Corp | $1,250.00 |
| INV-1002 | Globex | $890.50 |
| INV-1004 | Umbrella | $320.75 |
Loading — the loading prop sets aria-busy; the body renders Skeleton rows and the header stays visible
| Invoice | Customer | Amount |
|---|---|---|
Empty — TableEmpty spans every column via colSpan
| Invoice | Customer | Amount |
|---|---|---|
| No invoices found. | ||
Horizontal scroll — many columns overflow the container, which scrolls and stays keyboard-focusable
| Invoice | Customer | Status | Method | Issued | Due | Amount |
|---|---|---|---|---|---|---|
| INV-1001 | Acme Corp | Paid | Card | 2026-08-01 | 2026-08-31 | $1,250.00 |
| INV-1002 | Globex | Pending | Transfer | 2026-08-01 | 2026-08-31 | $890.50 |
| INV-1003 | Initech | Overdue | Card | 2026-08-01 | 2026-08-31 | $4,200.00 |
| INV-1004 | Umbrella | Paid | PayPal | 2026-08-01 | 2026-08-31 | $320.75 |
Code Example
"use client";
import * as React from "react";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableEmpty,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
/**
* Live Table example — `"use client"` to hold the selection Set and the sort
* state for the interactive sections. The Table parts themselves are plain
* elements; only the sortable header's control is interactive.
*
* Every section renders a real semantic `<table>` with a `<TableCaption>` (or
* `aria-label`) so it always has an accessible name.
*/
type Invoice = {
id: string;
customer: string;
status: "Paid" | "Pending" | "Overdue";
method: string;
amount: number;
};
const INVOICES: Invoice[] = [
{ id: "INV-1001", customer: "Acme Corp", status: "Paid", method: "Card", amount: 1250.0 },
{ id: "INV-1002", customer: "Globex", status: "Pending", method: "Transfer", amount: 890.5 },
{ id: "INV-1003", customer: "Initech", status: "Overdue", method: "Card", amount: 4200.0 },
{ id: "INV-1004", customer: "Umbrella", status: "Paid", method: "PayPal", amount: 320.75 },
];
const money = (n: number) =>
n.toLocaleString("en-US", { style: "currency", currency: "USD" });
const statusVariant: Record<
Invoice["status"],
"secondary" | "outline" | "destructive"
> = {
Paid: "secondary",
Pending: "outline",
Overdue: "destructive",
};
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-sm font-medium leading-snug text-muted-foreground">
{children}
</h3>
);
}
export function TableShowcase() {
const [selected, setSelected] = React.useState<Set<string>>(
() => new Set(["INV-1002"]),
);
const [sort, setSort] = React.useState<{
key: "customer" | "amount";
direction: "asc" | "desc";
}>({ key: "amount", direction: "desc" });
const allSelected = selected.size === INVOICES.length;
const someSelected = selected.size > 0 && !allSelected;
const toggleAll = () =>
setSelected(
allSelected ? new Set() : new Set(INVOICES.map((row) => row.id)),
);
const toggleRow = (id: string) =>
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
return next;
});
const sortedInvoices = React.useMemo(() => {
const copy = [...INVOICES];
copy.sort((a, b) => {
const cmp =
sort.key === "amount"
? a.amount - b.amount
: a.customer.localeCompare(b.customer);
return sort.direction === "asc" ? cmp : -cmp;
});
return copy;
}, [sort]);
const nextDirection = (key: "customer" | "amount") =>
sort.key === key && sort.direction === "asc" ? "desc" : "asc";
return (
<div className="flex flex-col gap-10">
{/* Basic ------------------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>Basic — caption, column headers, rows</SectionHeading>
<Table>
<TableCaption>Recent invoices</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.id}</TableCell>
<TableCell>{row.customer}</TableCell>
<TableCell>
<Badge variant={statusVariant[row.status]}>{row.status}</Badge>
</TableCell>
<TableCell align="end" className="tabular-nums">
{money(row.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
{/* Totals footer -------------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>Footer — a totals row in the table foot</SectionHeading>
<Table>
<TableCaption>Invoice totals</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.id}</TableCell>
<TableCell>{row.customer}</TableCell>
<TableCell align="end" className="tabular-nums">
{money(row.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={2}>Total</TableCell>
<TableCell align="end" className="tabular-nums">
{money(INVOICES.reduce((sum, row) => sum + row.amount, 0))}
</TableCell>
</TableRow>
</TableFooter>
</Table>
</section>
{/* Compact density --------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>Density — “compact” tightens cell padding</SectionHeading>
<Table density="compact">
<TableCaption>Payment methods (compact)</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Method</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.id}</TableCell>
<TableCell>{row.method}</TableCell>
<TableCell align="end" className="tabular-nums">
{money(row.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
{/* Selectable ------------------------------------------------------------ */}
<section className="flex flex-col gap-3">
<SectionHeading>
Selectable rows — a leading Checkbox column; the header checkbox is
indeterminate on a partial selection ({selected.size} selected)
</SectionHeading>
<Table>
<TableCaption>Select invoices to export</TableCaption>
<TableHeader>
<TableRow>
<TableHead className="w-0">
<Checkbox
aria-label="Select all rows"
checked={allSelected}
indeterminate={someSelected}
onChange={toggleAll}
/>
</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((row) => (
<TableRow key={row.id} selected={selected.has(row.id)}>
<TableCell>
<Checkbox
aria-label={`Select ${row.id}`}
checked={selected.has(row.id)}
onChange={() => toggleRow(row.id)}
/>
</TableCell>
<TableCell className="font-medium">{row.id}</TableCell>
<TableCell>{row.customer}</TableCell>
<TableCell align="end" className="tabular-nums">
{money(row.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
{/* Sortable ------------------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
Sortable headers — the component sets aria-sort and the indicator; the
caller sorts the data
</SectionHeading>
<Table>
<TableCaption>Invoices, sortable by customer and amount</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead
sortable
sortDirection={sort.key === "customer" ? sort.direction : "none"}
onSort={() =>
setSort({ key: "customer", direction: nextDirection("customer") })
}
>
Customer
</TableHead>
<TableHead
align="end"
sortable
sortDirection={sort.key === "amount" ? sort.direction : "none"}
onSort={() =>
setSort({ key: "amount", direction: nextDirection("amount") })
}
>
Amount
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sortedInvoices.map((row) => (
<TableRow key={row.id}>
<TableCell className="font-medium">{row.id}</TableCell>
<TableCell>{row.customer}</TableCell>
<TableCell align="end" className="tabular-nums">
{money(row.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
{/* Loading ------------------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
Loading — the loading prop sets aria-busy; the body renders Skeleton
rows and the header stays visible
</SectionHeading>
<Table loading>
<TableCaption>Invoices (loading)</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{Array.from({ length: 3 }, (_, i) => (
<TableRow key={i}>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-32" />
</TableCell>
<TableCell align="end">
<Skeleton className="ml-auto h-4 w-20" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</section>
{/* Empty -------------------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>Empty — TableEmpty spans every column via colSpan</SectionHeading>
<Table>
<TableCaption>Invoices matching “archived”</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableEmpty colSpan={3}>No invoices found.</TableEmpty>
</TableBody>
</Table>
</section>
{/* Horizontal scroll ----------------------------------------------------- */}
<section className="flex flex-col gap-3">
<SectionHeading>
Horizontal scroll — many columns overflow the container, which scrolls
and stays keyboard-focusable
</SectionHeading>
<div className="max-w-md">
<Table>
<TableCaption>Wide invoice detail</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Customer</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead>Issued</TableHead>
<TableHead>Due</TableHead>
<TableHead align="end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{INVOICES.map((row) => (
<TableRow key={row.id}>
<TableCell className="whitespace-nowrap font-medium">
{row.id}
</TableCell>
<TableCell className="whitespace-nowrap">
{row.customer}
</TableCell>
<TableCell>
<Badge variant={statusVariant[row.status]}>{row.status}</Badge>
</TableCell>
<TableCell className="whitespace-nowrap">{row.method}</TableCell>
<TableCell className="whitespace-nowrap">2026-08-01</TableCell>
<TableCell className="whitespace-nowrap">2026-08-31</TableCell>
<TableCell align="end" className="whitespace-nowrap tabular-nums">
{money(row.amount)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</section>
</div>
);
}Variants
No visual variants. The one axis is density:
| Prop | Values | Effect |
|---|---|---|
Table density | comfortable (default) · compact | Cell padding: py-3 px-4 → py-2 px-3; header height h-10 → h-9 |
TableCell / TableHead align | start (default) · center · end | Text alignment; end is the numeric-column alignment |
TableHead scope | col (default) · row | <th scope="col"> vs <th scope="row"> |
TableHead sortable | false (default) · true | Renders the sort <button> + indicator, manages aria-sort |
TableRow selected | false (default) · true | data-state="selected" row highlight |
Column widths, which columns exist, how many rows, and whether a column is sortable or
selectable are all the caller’s JSX — there is no columns prop.
States
| Part | State | Look |
|---|---|---|
TableRow (body) | Resting | Transparent; --foreground text |
TableRow (body) | Hover | --muted at 50% — a quiet row wash |
TableRow | selected (data-state="selected") | --accent background across the row |
TableHead | Resting | --muted fill, --muted-foreground text, --border bottom rule |
TableHead sort <button> | Hover | Text shifts to --foreground |
TableHead sort <button> | Focus (keyboard) | 2px --ring ring (Button’s focus contract) |
TableHead | Sorted | aria-sort="ascending" / "descending"; indicator is ChevronUp / ChevronDown |
TableHead | Sortable, unsorted | No aria-sort; indicator is a dimmed ChevronsUpDown |
Table container | Focus (keyboard) | Inset 2px --ring ring — the wide table is scrollable, so it’s a tab stop |
Table | loading | aria-busy="true" on the <table>; body shows <Skeleton> rows, header stays |
TableEmpty | — | One centred --muted-foreground cell spanning all columns |
Loading is a documented composition, not a prop that renders rows: set loading on
<Table> and render N <TableRow>s of <TableCell> wrapping a <Skeleton> in the body.
The header stays visible so the layout doesn’t jump when real rows arrive.
Empty is <TableEmpty colSpan={columnCount}> as the single child of <TableBody> — a
centred message, optionally with a call to action beside it.
Usage Guidance
Tokens
Table maps entirely to existing semantic tokens — --border, --muted / --muted-foreground, --accent, --foreground, --ring. It reuses Checkbox for row selection, Skeleton for the loading state, and Badge for status cells; none of those contracts change, and it introduces no component-specific styling values.
| Token | Where used | Rationale |
|---|---|---|
--border | TableHead bottom rule, TableCell row dividers, TableFooter top rule | The system hairline divider; no vertical rules by default — columns read from alignment and the header, not gridlines |
--muted / --muted-foreground | TableHeader fill + header text; TableCaption text; TableFooter tint; TableEmpty text | The quiet structural surface + secondary text pair — a header is chrome, not data |
--muted (50%) | TableRow hover | A low-emphasis “this row” wash, lighter than selection |
--accent | TableRow[data-state="selected"] | A stronger, distinct fill so a selected row is unmistakable against a hovered one |
--foreground | Cell text | Primary reading text, near-maximal contrast in both themes |
--ring | Focus ring on the scroll container and the sort <button> | The system focus token, identical geometry to Button / Input |
--scale-3 / --scale-4 (px-3/px-4, py-2/py-3) | Cell padding, compact vs comfortable | Spacing’s control rhythm |
--scale-9 / --scale-10 (h-9/h-10) | Header row height, compact vs comfortable | Matches Button’s control heights |
--icon-stroke-width / --scale-3-5 (size-3.5) | Sort indicator glyph | Icons’ inline size + stroke |
Numeric columns: align="end" plus tabular-nums in className — a utility, not a token
change.
Do / Don’t
Do
- Always render real
<table>markup through these parts, and always give the table an accessible name — a<TableCaption>oraria-labelon<Table>. - Use
<TableHead scope="col">for every column header, and<TableHead scope="row">for a leading identifier cell when a row has one (an invoice number, a person’s name). - Right-align numeric columns (
align="end") and addtabular-nums, so magnitudes compare at a glance. - Keep selection and sort state in the caller. Pass
selectedtoTableRowfor the highlight; put a<Checkbox>in a leading cell for the actual control; use the header<Checkbox>’sindeterminatefor a partial selection. - Let the container scroll horizontally on narrow viewports (the default). Keep the most identifying column first so it’s the last to scroll out of view.
- Show the header during
loadingand swap only the body to<Skeleton>rows.
Don’t
- Rebuild a table out of
<div>s withrole="table"/role="row"— use the semantic elements; assistive tech and browser find-in-page depend on them. - Put
aria-selectedon a<tr>. A semantic table row has no selected state to expose — the row<Checkbox>is what a screen reader announces;data-state="selected"is styling only. - Expect the component to sort, filter, or paginate.
onSortfires; you reorder the data and passsortDirectionback. - Set
aria-sorton more than the one currently-sorted column, or leave it as"none"on unsorted columns — omit it there. - Add vertical gridlines or zebra striping by default. If a very wide table needs row tracking, a subtle hover or a sparse rule is enough.
- Reach for a frozen header or frozen first column as a prop — Version 1 doesn’t ship those; a caller can add
position: stickyinclassNamewhere a specific screen needs it.
Accessibility
- Name: every table has an accessible name —
<caption>(preferred; it’s announced as the table’s label and is visible) oraria-label/aria-labelledbyon<Table>. - Header/cell relationships:
<th scope="col">associates a column header with its column;<th scope="row">associates a row header with its row. This is what lets a screen reader announce “Amount, $1,250.00” instead of a bare value. Usescopeon every header cell. - Sort: the sortable control is a real
<button>inside the<th>—Tabreaches it,Enter/Spaceactivate it, it has its own--ringfocus ring.aria-sorton the<th>is"ascending"/"descending"for the sorted column and is absent on every other column (including sortable-but-unsorted ones). The indicator icon isaria-hidden. - Selection: the row
<Checkbox>is a native checkbox — it carries the selection state and its own label (aria-label="Select INV-1003"). The “select all” header checkbox uses the nativeindeterminateproperty for a partial selection. The<tr>itself gets onlydata-state="selected"for the visual highlight — noaria-selected, which isn’t a valid state on a semantic row. - Loading:
aria-busy="true"on the<table>tells assistive tech the content is updating; the<Skeleton>shapes arearia-hidden(Skeleton’s own contract), so a screen reader hears “busy”, not a wall of empty cells. - Scrolling: a wide table’s container is
tabIndex={0}so a keyboard-only user can scroll it left/right with the arrow keys (WCAG 2.1.1). It is not givenrole="region"/aria-label— the table’s caption is its name, and adding a landmark per table would be landmark noise. - Keyboard model: there is none beyond the native controls. Cells are not arrow-key
navigable; this is a semantic table, not an ARIA grid.
Tabmoves through the interactive elements a table happens to contain (sort buttons, checkboxes, links), in DOM order. A screen reader’s own table-navigation commands work because the markup is real. - Contrast:
--foregroundon--backgroundfor cell text;--muted-foregroundon--mutedfor headers (both meet WCAG AA in light and dark, unchanged from their foundation definitions). The selected-row--accentfill keeps--foregroundtext above AA. - Motion: the only transition is the row-hover colour; it respects
prefers-reduced-motionvia the sharedtransition-colorstreatment.
Related Components / Patterns
- Checkbox — row selection.
- Skeleton — the loading state.
- Badge — status cells.
- Empty States — what a table shows when it has no rows.