Skip to content
Monogem

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>. className merges onto the <table>; containerClassName onto the scroll <div>. The container is overflow-x: auto and carries tabIndex={0} so a keyboard user can scroll a wide table; it is deliberately not a labelled role="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 as data-density on the <table> and read by the cells through a group-data variant — it changes cell padding only, nothing structural, and needs no context.
    • loading — sets aria-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 pass aria-label / aria-labelledby on <Table>.
  • TableHeader / TableBody / TableFooter — thin <thead> / <tbody> / <tfoot> wrappers. TableFooter carries the divider + --muted tint for a totals row.
  • TableRow — a <tr>. selected applies the row highlight via data-state="selected" and nothing else — the row’s own <Checkbox> carries the real selection state, so there is no aria-selected on the <tr>.
  • TableHead — one <th> primitive for both column and row headers, via scope ("col" default, "row" for a leading row-header cell). When sortable, it renders an internal real <button> (native Enter / Space, own focus ring) with a directional indicator, and sets aria-sort on the <th> — "ascending" / "descending" while sortDirection is "asc" / "desc", and omitted otherwise. It never sorts data; onSort is the caller’s hook.
  • TableCell — a <td>. align is "start" (default), "center", or "end"; "end" is for numeric columns — add tabular-nums in className there so digits line up.
  • TableEmpty — a small <tr><td> helper for the empty state. colSpan is required (the cell must span every column); there is no auto-detection.

Live Example

Basic — caption, column headers, rows

Recent invoices
InvoiceCustomerStatusAmount
INV-1001Acme CorpPaid$1,250.00
INV-1002GlobexPending$890.50
INV-1003InitechOverdue$4,200.00
INV-1004UmbrellaPaid$320.75

Footer — a totals row in the table foot

Invoice totals
InvoiceCustomerAmount
INV-1001Acme Corp$1,250.00
INV-1002Globex$890.50
INV-1003Initech$4,200.00
INV-1004Umbrella$320.75
Total$6,661.25

Density — “compact” tightens cell padding

Payment methods (compact)
InvoiceMethodAmount
INV-1001Card$1,250.00
INV-1002Transfer$890.50
INV-1003Card$4,200.00
INV-1004PayPal$320.75

Selectable rows — a leading Checkbox column; the header checkbox is indeterminate on a partial selection (1 selected)

Select invoices to export
InvoiceCustomerAmount
INV-1001Acme Corp$1,250.00
INV-1002Globex$890.50
INV-1003Initech$4,200.00
INV-1004Umbrella$320.75

Sortable headers — the component sets aria-sort and the indicator; the caller sorts the data

Invoices, sortable by customer and amount
Invoice
INV-1003Initech$4,200.00
INV-1001Acme Corp$1,250.00
INV-1002Globex$890.50
INV-1004Umbrella$320.75

Loading — the loading prop sets aria-busy; the body renders Skeleton rows and the header stays visible

Invoices (loading)
InvoiceCustomerAmount

Empty — TableEmpty spans every column via colSpan

Invoices matching “archived”
InvoiceCustomerAmount
No invoices found.

Horizontal scroll — many columns overflow the container, which scrolls and stays keyboard-focusable

Wide invoice detail
InvoiceCustomerStatusMethodIssuedDueAmount
INV-1001Acme CorpPaidCard2026-08-012026-08-31$1,250.00
INV-1002GlobexPendingTransfer2026-08-012026-08-31$890.50
INV-1003InitechOverdueCard2026-08-012026-08-31$4,200.00
INV-1004UmbrellaPaidPayPal2026-08-012026-08-31$320.75

Code Example

table-showcase.tsx
"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:

PropValuesEffect
Table densitycomfortable (default) · compactCell padding: py-3 px-4 → py-2 px-3; header height h-10 → h-9
TableCell / TableHead alignstart (default) · center · endText alignment; end is the numeric-column alignment
TableHead scopecol (default) · row<th scope="col"> vs <th scope="row">
TableHead sortablefalse (default) · trueRenders the sort <button> + indicator, manages aria-sort
TableRow selectedfalse (default) · truedata-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

PartStateLook
TableRow (body)RestingTransparent; --foreground text
TableRow (body)Hover--muted at 50% — a quiet row wash
TableRowselected (data-state="selected")--accent background across the row
TableHeadResting--muted fill, --muted-foreground text, --border bottom rule
TableHead sort <button>HoverText shifts to --foreground
TableHead sort <button>Focus (keyboard)2px --ring ring (Button’s focus contract)
TableHeadSortedaria-sort="ascending" / "descending"; indicator is ChevronUp / ChevronDown
TableHeadSortable, unsortedNo aria-sort; indicator is a dimmed ChevronsUpDown
Table containerFocus (keyboard)Inset 2px --ring ring — the wide table is scrollable, so it’s a tab stop
Tableloadingaria-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.

TokenWhere usedRationale
--borderTableHead bottom rule, TableCell row dividers, TableFooter top ruleThe system hairline divider; no vertical rules by default — columns read from alignment and the header, not gridlines
--muted / --muted-foregroundTableHeader fill + header text; TableCaption text; TableFooter tint; TableEmpty textThe quiet structural surface + secondary text pair — a header is chrome, not data
--muted (50%)TableRow hoverA low-emphasis “this row” wash, lighter than selection
--accentTableRow[data-state="selected"]A stronger, distinct fill so a selected row is unmistakable against a hovered one
--foregroundCell textPrimary reading text, near-maximal contrast in both themes
--ringFocus 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 comfortableSpacing’s control rhythm
--scale-9 / --scale-10 (h-9/h-10)Header row height, compact vs comfortableMatches Button’s control heights
--icon-stroke-width / --scale-3-5 (size-3.5)Sort indicator glyphIcons’ 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> or aria-label on <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 add tabular-nums, so magnitudes compare at a glance.
  • Keep selection and sort state in the caller. Pass selected to TableRow for the highlight; put a <Checkbox> in a leading cell for the actual control; use the header <Checkbox>’s indeterminate for 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 loading and swap only the body to <Skeleton> rows.

Don’t

  • Rebuild a table out of <div>s with role="table" / role="row" — use the semantic elements; assistive tech and browser find-in-page depend on them.
  • Put aria-selected on 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. onSort fires; you reorder the data and pass sortDirection back.
  • Set aria-sort on 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: sticky in className where 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) or aria-label / aria-labelledby on <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. Use scope on every header cell.
  • Sort: the sortable control is a real <button> inside the <th> — Tab reaches it, Enter / Space activate it, it has its own --ring focus ring. aria-sort on the <th> is "ascending" / "descending" for the sorted column and is absent on every other column (including sortable-but-unsorted ones). The indicator icon is aria-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 native indeterminate property for a partial selection. The <tr> itself gets only data-state="selected" for the visual highlight — no aria-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 are aria-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 given role="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. Tab moves 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: --foreground on --background for cell text; --muted-foreground on --muted for headers (both meet WCAG AA in light and dark, unchanged from their foundation definitions). The selected-row --accent fill keeps --foreground text above AA.
  • Motion: the only transition is the row-hover colour; it respects prefers-reduced-motion via the shared transition-colors treatment.

Related Components / Patterns