Data tables
Density, alignment, sorting, filtering, selection and bulk actions: tables that stay readable at a hundred rows.
Tables are for comparing. Every decision below serves one goal: let people scan a column, compare rows and act on them without losing their place.
6 of 6 invoices
"use client"
import * as React from "react"
import {
ArrowDownIcon,
ArrowUpIcon,
ArrowUpDownIcon,
SearchIcon,
Trash2Icon,
XIcon,
} from "lucide-react"
import { Badge, BadgeDot } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import {
InputGroup,
InputGroupAddon,
InputGroupInput,
} from "@/components/ui/input-group"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
import { cn } from "@/lib/utils"
type Status = "Paid" | "Pending" | "Overdue"
type Invoice = {
id: string
customer: string
amount: number
status: Status
due: string
}
const invoices: Invoice[] = [
{
id: "INV-2041",
customer: "Northwind Traders",
amount: 12400,
status: "Paid",
due: "2026-09-02",
},
{
id: "INV-2042",
customer: "Halden Studio",
amount: 3180.5,
status: "Pending",
due: "2026-09-28",
},
{
id: "INV-2043",
customer: "Meridian Labs",
amount: 890,
status: "Overdue",
due: "2026-08-30",
},
{
id: "INV-2044",
customer: "Kestrel Health",
amount: 25600,
status: "Paid",
due: "2026-09-10",
},
{
id: "INV-2045",
customer: "Lumen Freight",
amount: 7420.75,
status: "Pending",
due: "2026-10-04",
},
{
id: "INV-2046",
customer: "Arbor & Finch",
amount: 1999,
status: "Overdue",
due: "2026-09-01",
},
]
const statusVariant = {
Paid: "success",
Pending: "warning",
Overdue: "destructive",
} as const
// The unit lives in the column header, so cells hold plain numbers. Grouping
// always keeps 4- and 5-digit amounts aligned in languages that leave 4-digit
// numbers ungrouped (Polish: 7 420,75 next to 25 600,00).
const amount = new Intl.NumberFormat("en-US", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
useGrouping: "always",
})
// Due dates are calendar days: formatted in UTC, the server and a browser in
// any time zone print the same day.
const date = new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
})
type SortKey = "customer" | "amount" | "due"
type Sort = { key: SortKey; dir: "asc" | "desc" }
function SortButton({
column,
sort,
onSort,
children,
}: {
column: SortKey
sort: Sort
onSort: (sort: Sort) => void
children: React.ReactNode
}) {
const active = sort.key === column
const Icon = !active
? ArrowUpDownIcon
: sort.dir === "asc"
? ArrowUpIcon
: ArrowDownIcon
return (
<button
type="button"
onClick={() =>
onSort({
key: column,
dir: active && sort.dir === "asc" ? "desc" : "asc",
})
}
className={cn(
// A 24px target around the label (WCAG 2.5.8).
"-mx-1.5 inline-flex h-6 items-center gap-1 rounded-md px-1.5 focus-ring-inset transition-colors duration-fast hover:text-foreground in-[.text-right]:flex-row-reverse",
active && "text-foreground"
)}
>
{children}
<Icon className="size-3" aria-hidden="true" />
</button>
)
}
export default function DataTable() {
const [query, setQuery] = React.useState("")
const [status, setStatus] = React.useState<string[]>(["all"])
const [sort, setSort] = React.useState<Sort>({ key: "due", dir: "asc" })
const [selected, setSelected] = React.useState<Set<string>>(new Set())
const [rows, setRows] = React.useState(invoices)
const visible = React.useMemo(() => {
const filter = status[0] ?? "all"
return rows
.filter((row) => filter === "all" || row.status === filter)
.filter((row) =>
`${row.id} ${row.customer}`.toLowerCase().includes(query.toLowerCase())
)
.sort((a, b) => {
const result =
sort.key === "amount"
? a.amount - b.amount
: a[sort.key].localeCompare(b[sort.key])
return sort.dir === "asc" ? result : -result
})
}, [rows, status, query, sort])
// The selection bar acts on the rows in view: a selected row that the
// filter or the search hides is neither counted nor deleted.
const selectedRows = visible.filter((row) => selected.has(row.id))
const allSelected =
visible.length > 0 && selectedRows.length === visible.length
const someSelected = selectedRows.length > 0
function toggleAll() {
setSelected(allSelected ? new Set() : new Set(visible.map((row) => row.id)))
}
function toggle(id: string) {
setSelected((current) => {
const next = new Set(current)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
})
}
const ariaSort = (column: SortKey) =>
sort.key === column
? sort.dir === "asc"
? "ascending"
: "descending"
: "none"
return (
<div className="@container flex w-full flex-col gap-3">
<div className="flex flex-col gap-2 @lg:flex-row @lg:items-center">
<InputGroup className="h-8 w-full @lg:w-64">
<InputGroupAddon>
<SearchIcon />
</InputGroupAddon>
<InputGroupInput
placeholder="Search invoices"
aria-label="Search invoices"
value={query}
onChange={(event) => setQuery(event.target.value)}
/>
</InputGroup>
{/* On narrow screens the filters share the full width equally. */}
<ToggleGroup
value={status}
onValueChange={(value) => value.length && setStatus(value)}
variant="outline"
spacing={0}
size="sm"
aria-label="Filter by status"
className="w-full @lg:w-auto"
>
{["all", "Paid", "Pending", "Overdue"].map((value) => (
<ToggleGroupItem
key={value}
value={value}
className="flex-1 px-2 capitalize @lg:flex-none @lg:px-2.5"
>
{value}
</ToggleGroupItem>
))}
</ToggleGroup>
</div>
<div className="relative overflow-hidden rounded-xl border bg-card shadow-raised">
{/* Selection actions cover the header labels, next to the select-all
checkbox, so the rows below never move. */}
{someSelected && (
<div className="absolute top-0 right-0 left-10 z-raised flex h-10 items-center gap-3 bg-card pr-4 pl-3 text-sm">
<span className="font-medium numeric">
{`Selected: ${selectedRows.length}`}
</span>
<Button
variant="ghost"
size="xs"
className="text-danger-11 hover:bg-danger-3"
onClick={() => {
setRows((current) =>
current.filter((row) => !selectedRows.includes(row))
)
setSelected(new Set())
}}
>
<Trash2Icon data-icon="inline-start" />
Delete
</Button>
<Button
variant="ghost"
size="icon-xs"
className="ml-auto"
aria-label="Clear selection"
onClick={() => setSelected(new Set())}
>
<XIcon />
</Button>
</div>
)}
{/* Fixed columns: filtering and sorting never shift them. Phones drop
the invoice number and due date so the amount stays in view. */}
<Table className="table-fixed">
<TableHeader>
<TableRow>
<TableHead className="w-10">
<Checkbox
aria-label="Select all invoices"
checked={allSelected}
indeterminate={someSelected && !allSelected}
onCheckedChange={toggleAll}
/>
</TableHead>
<TableHead className="w-28 @max-xl:hidden">Invoice</TableHead>
<TableHead aria-sort={ariaSort("customer")}>
<SortButton column="customer" sort={sort} onSort={setSort}>
Customer
</SortButton>
</TableHead>
<TableHead className="w-32 @max-lg:hidden">Status</TableHead>
<TableHead
aria-sort={ariaSort("due")}
className="w-32 @max-2xl:hidden"
>
<SortButton column="due" sort={sort} onSort={setSort}>
Due
</SortButton>
</TableHead>
<TableHead
aria-sort={ariaSort("amount")}
className="w-32 text-right"
>
<SortButton column="amount" sort={sort} onSort={setSort}>
Amount (USD)
</SortButton>
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{visible.map((row) => (
<TableRow
key={row.id}
data-state={selected.has(row.id) ? "selected" : undefined}
>
<TableCell>
<Checkbox
aria-label={`Select ${row.id}`}
checked={selected.has(row.id)}
onCheckedChange={() => toggle(row.id)}
/>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground @max-xl:hidden">
{row.id}
</TableCell>
<TableCell className="font-medium">
<span className="block truncate">{row.customer}</span>
{/* Narrow tables fold the status under the customer. */}
<span className="mt-1 block @lg:hidden">
<Badge variant={statusVariant[row.status]}>
<BadgeDot />
{row.status}
</Badge>
</span>
</TableCell>
<TableCell className="@max-lg:hidden">
<Badge variant={statusVariant[row.status]}>
<BadgeDot />
{row.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground @max-2xl:hidden">
{date.format(new Date(`${row.due}T12:00:00Z`))}
</TableCell>
<TableCell className="text-right font-medium">
{amount.format(row.amount)}
</TableCell>
</TableRow>
))}
{visible.length === 0 && (
<TableRow>
<TableCell
colSpan={6}
className="h-28 text-center whitespace-normal text-muted-foreground"
>
No invoices match your filters.{" "}
<Button
variant="link"
onClick={() => {
setQuery("")
setStatus(["all"])
}}
>
Clear filters
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<p className="text-xs text-muted-foreground numeric">
{`${visible.length} of ${rows.length} invoices`}
</p>
</div>
)
}Alignment
- Text left, numbers right. Right-aligned numbers with tabular figures line up digit by digit. prfct tables use tabular figures by default.
- Headers align with their content.
- Units in the header, not repeated in every cell, when a column shares one.
Density
Rows are 48px by default — comfortable for mixed content. For dense operational tables, use 40px rows with sm controls; don't go below 32px. Keep one density per table.
Sorting and filtering
- Make sortable headers buttons, with an icon that shows the direction, and set
aria-sorton the header cell. - Put search and filters above the table, and show the result count below it.
- When filters exclude everything, keep the headers and show an empty state inside the table with a way to clear filters.
Selection and bulk actions
- A checkbox column with a header checkbox that is indeterminate when some — but not all — rows are selected.
- When rows are selected, a bulk action bar replaces the toolbar, states the count and offers the actions. Destructive bulk actions follow Destructive actions.
- Selected rows use
data-state="selected", tinted withbrand-2.
Pagination
Use pagination when position matters — people need to find the invoice from last March again. Use infinite scroll only for feeds that are browsed, not searched. Always show the total.
Accessibility
- Use real
tablemarkup:thead,thwithscope, and acaption(visually hidden if needed). - Every row checkbox has a label that names the row: Select INV-2043.
- Row actions are reachable with the keyboard; avoid actions that only appear on hover.