Summit
ComponentsData

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

Invoices issued in October.
InvoiceStatusAmount
INV-2026-014Sent$7,703.33
INV-2026-011Paid$3,219.30
Total$10,922.63
ElementUsage
ContainerRequired
Column headerRequired
HeaderRequired
RowRequired
CellRequired
FooterOptional
CaptionOptional

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.

DoAdd a Checkbox column to a Table whose rows can be selected so that the checked box marks the row.
Don’tWhen the tint alone marks a selected row, users can miss it, and screen reader users aren’t told that the row is selected.

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.

DoSet text-end on the header and on each cell of the column.
Don’tWhen amounts are aligned to the start edge, the decimal points don’t line up, and users have to read every digit to find the largest amount.

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

StringRuleExampleCounterexample
Column headerUse a noun in sentence case, with no colon“Amount”“Amount:”
CaptionSay in one sentence what the Table lists“The four invoices you issued last.”“Table 1”
aria-label of a row’s CheckboxName the record“Select INV-2026-014”“Select row”
Header of a column of actionsUse 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/table

Usage

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.

Add a Caption and a Total
<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.

Select Rows
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>.

Add Row Actions
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.

Page the Rows
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.

On this page