Table
A Table allows users to read and compare records in rows and columns, under a header row that names each column.
When to Use
- for records that share the same fields, such as invoices with a number, a client, a status, and an amount
- when users compare one value down a column, such as the amounts of a month
- to select rows for one action, with a Checkbox column
When Not to Use
- for a short list with one or two facts for each record (use the Item component)
- for the fields of one record (use a
dl, as the Card preview does for the lines of an invoice) - for a trend over time (use a Chart)
- for a list with no records yet (use the Empty component in place of the Table)
Anatomy
| Invoice | Status | Amount |
|---|---|---|
| INV-2026-014 | Sent | $7,703.33 |
| INV-2026-011 | Paid | $3,219.30 |
| Total | $10,922.63 | |
| Element | Usage |
|---|---|
| Container | Required |
| Column header | Required |
| Header | Required |
| Row | Required |
| Cell | Required |
| Footer | Optional |
| Caption | Optional |
Table renders the container and the table inside it. Place TableCaption first because the HTML Standard allows a caption only as the first child of a table. The caption is displayed under the Table.
States
The product keeps the selection. To mark a row as selected, set data-state="selected" on it and check the Checkbox in it. The tint of a selected row is faint, so users rely on the checked box to see the selection. See Elevation for the rule on marking a selection.
| Invoice | Amount | |
|---|---|---|
| INV-2026-014 | $7,703.33 | |
| INV-2026-013 | $1,986.77 | |
| INV-2026-012 | $2,759.40 |
| Invoice | Amount |
|---|---|
| INV-2026-014 | $7,703.33 |
| INV-2026-013 | $1,986.77 |
| INV-2026-012 | $2,759.40 |
Behavior
A Table has no state of its own: it renders the rows that it receives.
Width and Overflow
By default, a Table fills its container, and the text in a cell doesn’t wrap. When the columns don’t fit, the Table scrolls sideways inside its own container, and the page around it doesn’t scroll. If you want the text of a cell to wrap, add whitespace-normal to the cell.
Rows
A row is as tall as its content. See Sizing for the heights.
Columns of Amounts
By default, TableHead and TableCell are aligned to the start edge. Set text-end on the header and the cells of a column of amounts. All digits have the same width, so amounts with the same number of decimals line up digit under digit. See Typography for the figures.
| Client | Amount |
|---|---|
| Atelier Brume | $18,420.00 |
| Harbour Yoga | $703.33 |
| Fable Bakery | $2,759.40 |
text-end on the header and on each cell of the column.| Client | Amount |
|---|---|
| Atelier Brume | $18,420.00 |
| Harbour Yoga | $703.33 |
| Fable Bakery | $2,759.40 |
Edge
A Table has no edge around itself. To frame it, wrap it in an element that has overflow-hidden rounded-xl ring-1 ring-foreground/10. In a Card, set px-2 on CardContent so that the text of a cell aligns with the Card’s title.
Pages and No Rows
If a Table is split into pages, place a Pagination under it, next to a count like “Showing 1 to 12 of 48”. If there are no rows, a Table shows only its header, so render the Empty component in its place.
Content
| String | Rule | Example | Counterexample |
|---|---|---|---|
| Column header | Use a noun in sentence case, with no colon | “Amount” | “Amount:” |
| Caption | Say in one sentence what the Table lists | “The four invoices you issued last.” | “Table 1” |
aria-label of a row’s Checkbox | Name the record | “Select INV-2026-014” | “Select row” |
| Header of a column of actions | Use a visually hidden name, with sr-only | “Actions” | An empty header |
Accessibility
Table renders the native elements, so the browser provides the roles, and screen readers read each cell with its column header.
If a Table has no caption, it has no name. To name it without a caption, set aria-label on Table; the attribute is passed to the table element.
A Checkbox in a Table needs an aria-label that names its row, such as “Select INV-2026-014”, so that screen
readers can announce which record it selects. The box has no visible label, and WCAG 2.2 SC 4.1.2 Name, Role, Value requires a name.
If a column has no visible header, provide a visually hidden name with sr-only, such as “Actions”. For a status in a cell, follow the rule for a Badge: state the status in words in the label.
Installation
npx shadcn@latest add @summit/tableUsage
The product sorts, filters, and pages the rows, and it keeps the selection.
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Client</TableHead>
<TableHead className="text-end">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-medium">INV-2026-014</TableCell>
<TableCell>Atelier Brume</TableCell>
<TableCell className="text-end">$7,703.33</TableCell>
</TableRow>
</TableBody>
</Table>;Add a Caption and a Total
Place TableCaption first and TableFooter last. In the preview, the “Total” cell has colSpan={3} so that it spans the three columns before the amount.
<Table>
<TableCaption>The four invoices you issued last.</TableCaption>
<TableBody>
<TableRow>
<TableCell>INV-2026-014</TableCell>
<TableCell className="text-end">$7,703.33</TableCell>
</TableRow>
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell className="text-end">$7,703.33</TableCell>
</TableRow>
</TableFooter>
</Table>Select Rows
Keep the selection in state. Set data-state on the selected rows, and set indeterminate on the header’s Checkbox while some of the rows are selected.
import { useState } from 'react';
import { Checkbox } from '@/components/ui/checkbox';
const invoices = [{ number: 'INV-2026-014' }, { number: 'INV-2026-013' }];
const [selected, setSelected] = useState<string[]>([]);
const every = selected.length === invoices.length;
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8">
<Checkbox
aria-label="Select every invoice"
checked={every}
indeterminate={selected.length > 0 && !every}
onCheckedChange={(checked) => setSelected(checked ? invoices.map(({ number }) => number) : [])}
/>
</TableHead>
<TableHead>Invoice</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{invoices.map(({ number }) => (
<TableRow key={number} data-state={selected.includes(number) ? 'selected' : undefined}>
<TableCell>
<Checkbox
aria-label={`Select ${number}`}
checked={selected.includes(number)}
onCheckedChange={(checked) =>
setSelected((current) =>
checked ? [...current, number] : current.filter((entry) => entry !== number),
)
}
/>
</TableCell>
<TableCell className="font-medium">{number}</TableCell>
</TableRow>
))}
</TableBody>
</Table>;Add Row Actions
Place an icon-only Button that opens a Dropdown Menu in the last cell, and name the record in its aria-label. To show the same name in a Tooltip, pass the DropdownMenuTrigger to TooltipTrigger through render. In the header of that column, add <span className="sr-only">Actions</span>.
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { DotsThreeIcon } from '@phosphor-icons/react';
<TableCell>
<DropdownMenu>
<Tooltip>
<TooltipTrigger
render={
<DropdownMenuTrigger
render={<Button variant="ghost" size="icon-sm" aria-label="Actions for INV-2026-014" />}
/>
}
>
<DotsThreeIcon aria-hidden weight="bold" />
</TooltipTrigger>
<TooltipContent>Actions for INV-2026-014</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem>Send reminder</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>;Page the Rows
Place the count and the Pagination in one row under the Table.
import { Pagination, PaginationContent, PaginationItem, PaginationNext } from '@/components/ui/pagination';
<div className="flex flex-wrap items-center justify-between gap-3">
<p className="text-sm text-muted-foreground">Showing 1 to 12 of 48</p>
<Pagination className="mx-0 w-auto">
<PaginationContent>
<PaginationItem>
<PaginationNext href="/invoices?page=2" />
</PaginationItem>
</PaginationContent>
</Pagination>
</div>;API Reference
The parts accept the props of their elements and add none. Table passes its props and its className to the table, not to the container.