Table
pnpm dlx shadcn@latest add …/r/table.jsonAn accessible React table for Next.js and Tailwind CSS: rows and columns with sortable headers, selectable rows and rows that glide when sorted.
| Invoice | Status | Method | |||
|---|---|---|---|---|---|
| INV-1048 | Paid | Card | Oct 12, 2026 | $1,250.00 | |
| INV-1047 | Pending | Bank transfer | Oct 9, 2026 | $480.00 | |
| INV-1046 | Overdue | Card | Oct 3, 2026 | $2,900.00 | |
| INV-1045 | Paid | PayPal | Sep 28, 2026 | $760.50 | |
| INV-1044 | Paid | Bank transfer | Sep 21, 2026 | $3,400.00 | |
| INV-1043 | Pending | Card | Sep 15, 2026 | $150.00 | |
| Total | $8,940.50 | ||||
import {
Table,
TableBody,
TableCaption,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
export function Invoices() {
return (
<Table>
<TableCaption>Recent invoices</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>INV-1048</TableCell>
<TableCell className="text-right">$1,250.00</TableCell>
</TableRow>
</TableBody>
</Table>
)
}"use client";
import { Badge } from "@/components/ui/badge";
import { Checkbox } from "@/components/ui/checkbox";
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
type TableSortDirection,
} from "@/components/ui/table";
import { useState } from "react";
type Status = "paid" | "pending" | "overdue";
const invoices = [
{ id: "INV-1048", status: "paid", method: "Card", date: "2026-10-12", day: "Oct 12, 2026", amount: 1250 },
{
id: "INV-1047",
status: "pending",
method: "Bank transfer",
date: "2026-10-09",
day: "Oct 9, 2026",
amount: 480,
},
{ id: "INV-1046", status: "overdue", method: "Card", date: "2026-10-03", day: "Oct 3, 2026", amount: 2900 },
{
id: "INV-1045",
status: "paid",
method: "PayPal",
date: "2026-09-28",
day: "Sep 28, 2026",
amount: 760.5,
},
{
id: "INV-1044",
status: "paid",
method: "Bank transfer",
date: "2026-09-21",
day: "Sep 21, 2026",
amount: 3400,
},
{ id: "INV-1043", status: "pending", method: "Card", date: "2026-09-15", day: "Sep 15, 2026", amount: 150 },
] satisfies { id: string; status: Status; method: string; date: string; day: string; amount: number }[];
const statuses = {
paid: { label: "Paid", variant: "secondary" },
pending: { label: "Pending", variant: "outline" },
overdue: { label: "Overdue", variant: "destructive" },
} as const;
const money = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" });
const total = invoices.reduce((sum, invoice) => sum + invoice.amount, 0);
type Column = "date" | "amount";
/*
* Invoices you can sort by date or amount (the rows glide to their new places) and pick with the checkboxes.
* It sits on the page's own colour, as a table would, so the hover and the selected rows read as they do there.
*/
export default function TableInvoices() {
const [sort, setSort] = useState<{ column: Column; direction: Exclude<TableSortDirection, "none"> }>({
column: "date",
direction: "descending",
});
const [selected, setSelected] = useState<Set<string>>(() => new Set(["INV-1046"]));
const rows = [...invoices].sort((a, b) => {
const order = sort.column === "date" ? a.date.localeCompare(b.date) : a.amount - b.amount;
return sort.direction === "ascending" ? order : -order;
});
const directionOf = (column: Column) => (sort.column === column ? sort.direction : "none");
// A column sorts ascending first; pressing the sorted column again flips it
const sortBy = (column: Column) =>
setSort((current) =>
current.column === column
? { column, direction: current.direction === "ascending" ? "descending" : "ascending" }
: { column, direction: "ascending" },
);
const all = selected.size === invoices.length ? true : selected.size > 0 ? "indeterminate" : false;
const toggle = (id: string, on: boolean) =>
setSelected((current) => {
const next = new Set(current);
if (on) next.add(id);
else next.delete(id);
return next;
});
return (
<div className="w-full max-w-2xl rounded-lg bg-background px-2 pt-1 pb-3">
<Table>
<TableCaption>Recent invoices</TableCaption>
<TableHeader>
<TableRow>
<TableHead>
<Checkbox
size="sm"
aria-label="Select all invoices"
checked={all}
onCheckedChange={(on) =>
setSelected(on === true ? new Set(invoices.map((invoice) => invoice.id)) : new Set())
}
/>
</TableHead>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead>Method</TableHead>
<TableHead sort={directionOf("date")} onSort={() => sortBy("date")}>
Date
</TableHead>
<TableHead className="text-right" sort={directionOf("amount")} onSort={() => sortBy("amount")}>
Amount
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((invoice) => {
const on = selected.has(invoice.id);
const status = statuses[invoice.status];
return (
<TableRow key={invoice.id} data-state={on ? "selected" : undefined}>
<TableCell>
<Checkbox
size="sm"
aria-label={`Select ${invoice.id}`}
checked={on}
onCheckedChange={(value) => toggle(invoice.id, value === true)}
/>
</TableCell>
<TableCell className="font-medium">{invoice.id}</TableCell>
<TableCell>
<Badge variant={status.variant}>{status.label}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{invoice.method}</TableCell>
<TableCell className="text-muted-foreground">{invoice.day}</TableCell>
<TableCell className="text-right tabular-nums">{money.format(invoice.amount)}</TableCell>
</TableRow>
);
})}
</TableBody>
<TableFooter>
<TableRow>
<TableCell colSpan={5}>Total</TableCell>
<TableCell className="text-right tabular-nums">{money.format(total)}</TableCell>
</TableRow>
</TableFooter>
</Table>
</div>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motionAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
import { TableBody, TableSortButton, type TableSortDirection, } from "@/components/ui/table-motion"; import { cn } from "@/lib/utils"; import type { ComponentProps } from "react"; /* * Table (owner's roadmap, 2026-10-16: "Data in rows and columns, with sortable headers"), with shadcn's parts: * Table (inside a container that scrolls sideways), TableHeader, TableBody, TableFooter, TableRow, TableHead, * TableCell and TableCaption. A real HTML table, so screen readers announce rows, columns and headers. * - Borders: quiet dividers between rows and none round the outside (design.md: few borders). The lines belong * to the cells, with the table's borders kept separate and no spacing between them, so each row carries its * own line when it glides. * - Sorting: give TableHead `sort` and `onSort`. Its label becomes a button, the header gets aria-sort (only the * sorted column has one, as in the WAI-ARIA sortable table), and the arrow turns between up and down. * - When sorting changes the rows' order, they glide to their new places (TableBody, in table-motion.tsx with * the sort button: the two client parts). Give rows a stable `key` so they can. * - Rows: hover is a colour change only, and a selected row (data-state="selected") is muted. A checkbox column * stays as narrow as its checkbox. */ /** Wraps the table in a container that scrolls sideways when the columns don't fit */ function Table({ className, ...props }: ComponentProps<"table">) { return ( <div data-slot="table-container" className="relative w-full overflow-x-auto"> <table data-slot="table" className={cn("w-full caption-bottom border-separate border-spacing-0 text-sm", className)} {...props} /> </div> ); } function TableHeader({ className, ...props }: ComponentProps<"thead">) { return ( <thead data-slot="table-header" // Header rows don't take the body's hover className={cn("[&_tr]:hover:bg-transparent", className)} {...props} /> ); } function TableFooter({ className, ...props }: ComponentProps<"tfoot">) { return ( <tfoot data-slot="table-footer" // One line between the body and the footer (the body's last row has none), and none under it className={cn( "font-medium [&_tr]:hover:bg-transparent [&>tr:first-child>*]:border-t [&>tr:first-child>*]:border-border [&>tr:last-child>*]:border-b-0", className, )} {...props} /> ); } function TableRow({ className, ...props }: ComponentProps<"tr">) { return ( <tr data-slot="table-row" className={cn( "transition-colors duration-(--duration-fast) ease-out hover:bg-muted/50 data-[state=selected]:bg-muted", className, )} {...props} /> ); } export type TableHeadProps = ComponentProps<"th"> & { /** This column's sort. Sets aria-sort on the sorted column and turns the arrow. */ sort?: TableSortDirection; /** Makes the header a button that sorts the column; called when it's pressed */ onSort?: () => void; }; function TableHead({ className, sort, onSort, children, ...props }: TableHeadProps) { return ( <th data-slot="table-head" aria-sort={sort === "ascending" || sort === "descending" ? sort : undefined} className={cn( "h-10 border-b border-border px-3 text-start align-middle font-medium whitespace-nowrap text-muted-foreground", "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:flex", className, )} {...props} > {onSort ? ( <TableSortButton direction={sort ?? "none"} onClick={onSort}> {children} </TableSortButton> ) : ( children )} </th> ); } function TableCell({ className, ...props }: ComponentProps<"td">) { return ( <td data-slot="table-cell" className={cn( "border-b border-border px-3 py-2.5 align-middle whitespace-nowrap", "[&:has([role=checkbox])]:w-px [&:has([role=checkbox])]:pe-0 [&>[role=checkbox]]:flex", className, )} {...props} /> ); } function TableCaption({ className, ...props }: ComponentProps<"caption">) { return ( <caption data-slot="table-caption" className={cn("mt-4 text-sm text-muted-foreground", className)} {...props} /> ); } export type { TableSortButtonProps, TableSortDirection } from "@/components/ui/table-motion"; export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow, TableSortButton, };"use client"; import { duration, spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { ArrowUpIcon } from "lucide-react"; import { type AnimationPlaybackControls, animate, type MotionValue, motion, motionValue, useReducedMotion, } from "motion/react"; import { type ComponentProps, useEffect, useRef } from "react"; /* * The client parts of Table: the body, whose rows glide when their order changes, and the sort button with its * arrow. table.tsx re-exports them, so every other part stays a server component. * - Rows glide (FLIP, .agents/rules/motion.md): when sorting reorders the rows, each row starts from where it * was drawn and glides to its new place on spring.snappy. Only a transform moves, and rows stay real `tr`s. * Places are offsets (layout pixels), so a zoomed or scaled page doesn't double the trip. Re-sorting mid-glide * leaves from where each row is now. Rows that are added, removed or resized just take their places: only a * new order glides. With reduced motion nothing moves. * - The sort arrow turns between up and down on spring.snappy. Unsorted, it's hidden until you point at the * header (or focus it); when a column stops being sorted it fades out where it was, then resets. */ export type TableSortDirection = "ascending" | "descending" | "none"; /** A row on its way to its new place: its offset, and the spring taking it there */ type Glide = { y: MotionValue<number>; animation?: AnimationPlaybackControls; stop: () => void }; /** Where a row sits in its body, in layout pixels (offsets: transforms and zoom don't count) */ const placeOf = (row: HTMLTableRowElement, body: HTMLTableSectionElement) => row.offsetTop - body.offsetTop; function TableBody({ className, ref, ...props }: ComponentProps<"tbody">) { const node = useRef<HTMLTableSectionElement | null>(null); const reduce = useReducedMotion() ?? false; // Read by the observer below, which outlives renders const reduceRef = useRef(reduce); useEffect(() => { reduceRef.current = reduce; }, [reduce]); useEffect(() => { const body = node.current; if (!body) return; const glides = new Map<HTMLTableRowElement, Glide>(); let order: HTMLTableRowElement[] = []; let places = new Map<HTMLTableRowElement, number>(); /** Note each row's place, to glide from next time. Rows that left the table let go of their glide. */ const remember = () => { order = Array.from(body.rows); places = new Map(order.map((row) => [row, placeOf(row, body)])); for (const [row, glide] of glides) if (!places.has(row)) glide.stop(); }; // React moves the rows' nodes when their keys change order. The observer runs after it does and before the // browser paints, so each row can be put back where it was drawn, then let go. const onRowsChange = () => { const before = order; const was = places; remember(); if (reduceRef.current) return; const kept = order.filter((row) => was.has(row)); const keptBefore = before.filter((row) => places.has(row)); if (kept.every((row, index) => row === keptBefore[index])) return; for (const row of kept) { let glide = glides.get(row); const from = (was.get(row) ?? 0) - (places.get(row) ?? 0) + (glide?.y.get() ?? 0); // Still and already in place: nothing to do. A row mid-glide is placed again either way. if (from === 0 && !glide) continue; if (!glide) { const y = motionValue(0); const unsubscribe = y.on("change", (value) => { row.style.transform = value === 0 ? "" : `translateY(${value}px)`; }); const created: Glide = { y, stop: () => { created.animation?.stop(); unsubscribe(); row.style.transform = ""; glides.delete(row); }, }; glide = created; glides.set(row, glide); } // Placed with jump(), which resets the speed, so the spring doesn't count the jump as a push glide.animation?.stop(); glide.y.jump(from); glide.animation = animate(glide.y, 0, spring.snappy); } }; remember(); const rows = new MutationObserver(onRowsChange); rows.observe(body, { childList: true }); // Places change when the table reflows (a new width, rows that grow), so note them again const size = new ResizeObserver(remember); size.observe(body); return () => { rows.disconnect(); size.disconnect(); for (const glide of glides.values()) glide.stop(); }; }, []); return ( <tbody ref={(element) => { node.current = element; if (typeof ref === "function") ref(element); else if (ref) ref.current = element; }} data-slot="table-body" className={cn("[&>tr:last-child>*]:border-b-0", className)} {...props} /> ); } export type TableSortButtonProps = ComponentProps<"button"> & { /** The column's sort, which turns the arrow */ direction?: TableSortDirection; }; /** * The button inside a sortable TableHead. TableHead renders it for you when you give it `onSort`; use it directly * only to build a header of your own (set aria-sort on the header yourself then). */ function TableSortButton({ direction = "none", className, children, ...props }: TableSortButtonProps) { const reduce = useReducedMotion() ?? false; const sorted = direction !== "none"; return ( <button type="button" data-slot="table-sort-button" data-sort={direction} className={cn( "group/sort -mx-1.5 inline-flex h-(--size-control-sm) cursor-pointer items-center gap-1 rounded-md px-1.5 font-medium whitespace-nowrap", "transition-colors duration-(--duration-fast) ease-out hover:text-foreground", sorted && "text-foreground", // In a right-aligned column (numbers), the arrow goes first, so the label lines up with the values "in-[.text-right]:flex-row-reverse in-[.text-end]:flex-row-reverse", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", className, )} {...props} > {children} {/* The arrow always keeps its room, so the label never shifts as it appears */} <span aria-hidden className={cn( "inline-flex transition-opacity duration-(--duration-fast) ease-out", sorted ? "opacity-100" : "text-muted-foreground opacity-0 group-hover/sort:opacity-100 group-focus-visible/sort:opacity-100", )} > <motion.span className="inline-flex" initial={false} animate={{ rotate: direction === "descending" ? 180 : 0 }} // Turning, it springs. Leaving a column, it fades out pointing where it pointed, then resets. transition={ reduce ? { duration: 0 } : sorted ? spring.snappy : { duration: 0, delay: duration.fast } } > <ArrowUpIcon className="size-(--size-icon-sm)" /> </motion.span> </span> </button> ); } export { TableBody, TableSortButton };Update the import paths to match your project.
API reference
Table
The table, inside a container that scrolls sideways when the columns don't fit. Also accepts every native table prop. Name it with a TableCaption or aria-label.
| Prop | Type | Default | Description |
|---|
TableHeader
The header rows (thead). Its rows don't take the hover.
| Prop | Type | Default | Description |
|---|
TableBody
The body (tbody). When its rows change order, for example on a sort, they glide to their new places; give each row a stable key. Instant with reduced motion.
| Prop | Type | Default | Description |
|---|
TableFooter
The footer rows (tfoot), for totals. A line above it, in medium weight.
| Prop | Type | Default | Description |
|---|
TableRow
A row (tr). Also accepts every native tr prop.
| Prop | Type | Default | Description |
|---|---|---|---|
data-state? | "selected" | – | Marks the row as selected: it takes a muted background. |
TableHead
A header cell (th). Also accepts every native th prop. Add className="text-right" for a column of numbers; a sort arrow then goes before the label.
| Prop | Type | Default | Description |
|---|---|---|---|
sort? | "ascending" | "descending" | "none" | – | The column's sort. The sorted column gets aria-sort; the arrow points up for ascending and turns down for descending. |
onSort? | () => void | – | Makes the label a button that sorts the column. Called when it's pressed; update sort in response. Unsorted columns show their arrow on hover. |
TableCell
A data cell (td). Also accepts every native td prop, such as colSpan.
| Prop | Type | Default | Description |
|---|
TableCaption
The table's title, shown below it and read as its name.
| Prop | Type | Default | Description |
|---|
TableSortButton
The button TableHead renders for a sortable column. Use it directly only for a header of your own, and set aria-sort on that header yourself. Also accepts every native button prop.
| Prop | Type | Default | Description |
|---|---|---|---|
direction? | "ascending" | "descending" | "none" | "none" | Which way the arrow points; hidden until hover when none. |
Related components
An accessible React pagination for Next.js and Tailwind CSS: page links with ellipses, Previous and Next, and a pill that slides to the current page.
An accessible React checkbox for Next.js forms, with a check that draws itself in and an indeterminate state, built on Radix Checkbox.
- Stablev2.4.0Beta
An accessible React badge for Next.js and Tailwind CSS: small status labels in five variants, including one with a light travelling round its edge.