Table
Free · MITLined, striped, bordered, card-row and minimal tables with tones, three densities, shadows and corner shapes, tint, accent-bar or lift hover, row state tones and selection, sortable headers with aria-sort, sticky header, caption, footer totals, skeleton and empty states, fade or stagger entrances, and rows that stack into labelled cards on small containers.
Installation
$ npx shadcn@latest add https://goodui.dev/r/table.jsonThe file lands in components/goodui/table.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/table works too.
Usage
import { Table, type TableColumn } from "@/components/goodui/table"
type Invoice = { id: string; client: string; due: string; amount: number; status: "paid" | "overdue" }
const columns: TableColumn<Invoice>[] = [
{ key: "id", header: "Invoice", primary: true },
{ key: "client", header: "Client", sortable: true },
{ key: "due", header: "Due" },
{
key: "amount",
header: "Amount",
numeric: true,
sortable: true,
render: (r) => r.amount.toLocaleString("en-US", { style: "currency", currency: "USD" }),
},
]
export function Invoices({ invoices }: { invoices: Invoice[] }) {
return (
<Table
aria-label="Invoices"
columns={columns}
data={invoices}
rowTone={(r) => (r.status === "overdue" ? "danger" : "none")}
stack="md"
caption="October invoices"
/>
)
}When to use
- Showing a small or medium static list: invoices, a team, today's schedule, plan comparisons.
- Rows that carry a state: overdue invoices tinted red, drafts grey (rowTone or TableRow tone).
- Simple client-side sorting by clicking headers, with an accessible sort button and aria-sort.
- Tables that must stay readable on phones: stack turns rows into label/value cards below a container width.
- Thousands of rows, inline editing, filters, column resizing or export: use data-grid.
- A list of items with avatars and actions and no columns to compare: use a list or card layout.
- Key figures at a glance: use stat-card.
| If you need | Use |
|---|---|
| Default app table | variant="lined" (default) |
| Long rows that are hard to follow | variant="striped" |
| Dense comparisons, spreadsheets-like | variant="bordered" density="compact" |
| Rows that feel like separate items (team, bookings) | variant="cards" hover="lift" |
| Inside a card that already has a border | variant="minimal" |
| Status per row | rowTone with columns + data, or tone on TableRow |
| Long tables | stickyHeader with maxHeight |
| Phones | stack="md" plus primary on the title column |
Examples
Parts, like a plain <table>
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/goodui/table"
<Table variant="striped" density="compact" aria-label="Schedule">
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Client</TableHead>
<TableHead numeric>Price</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow tone="info">
<TableCell label="Time">09:30</TableCell>
<TableCell label="Client">Omar D.</TableCell>
<TableCell label="Price" numeric>$40</TableCell>
</TableRow>
</TableBody>
</Table>Controlled sort (you sort the data)
const [sort, setSort] = React.useState<TableSort | null>({ key: "rating", direction: "desc" })
const rows = React.useMemo(() => sortTeam(team, sort), [team, sort])
<Table columns={columns} data={rows} sort={sort} onSortChange={setSort} variant="cards" hover="lift" />Clickable, selectable rows
<Table
columns={columns}
data={team}
selectedKeys={picked}
onRowClick={(r) => togglePicked(r.id)}
aria-label="Team"
/>API reference
Table
| Prop | Type | Default |
|---|---|---|
| variant lined (default), striped, bordered (every cell), cards (each row a separate card) or minimal (no frame). | "lined" | "striped" | "bordered" | "cards" | "minimal" | "lined" |
| tone Accent for the sort arrow, selected rows, the accent hover bar and focus rings. | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | "primary" |
| density Cell padding and text size. | "compact" | "normal" | "relaxed" | "normal" |
| shadow Frame shadow. Ignored by minimal. | "none" | "sm" | "md" | "lg" | "none" |
| shape Corner radius of the frame (and of each row in cards). | "square" | "rounded" | "soft" | "rounded" |
| animation fade or stagger rows in on mount; remount (change key) to replay. | "none" | "fade" | "stagger" | "none" |
| hover Row hover style. | "none" | "tint" | "accent" | "lift" | "tint" |
| stack Below this container width (sm 480px, md 640px, lg 768px) rows turn into cards with a label per value. | "never" | "sm" | "md" | "lg" | "never" |
| stickyHeader Keeps the header visible while scrolling. With maxHeight it sticks inside the table's own scroll box. | boolean | false |
| maxHeight Makes the table scroll inside a box of this height (px number or CSS length). | number | string | — |
| caption | React.ReactNode | — |
| captionSide | "top" | "bottom" | "bottom" |
| columns Columns for the data shorthand. With children, columns and data are ignored. | TableColumn<T>[] | — |
| data | T[] | — |
| rowKey Stable key per row; defaults to row.id, then the index. | (row: T, index: number) => string | — |
| rowTone State color per row: a tinted background and a start-edge marker. | (row: T, index: number) => TableRowTone | undefined | — |
| selectedKeys Keys of rows drawn as selected. | string[] | — |
| onRowClick Makes rows clickable and focusable (Enter or Space activates). | (row: T, index: number) => void | — |
| sort Sort (controlled). null means unsorted. | TableSort | null | — |
| defaultSort | TableSort | null | null |
| onSortChange Called when a sortable header is clicked: asc, desc, then unsorted. With sort controlled, sort the data yourself. | (sort: TableSort | null) => void | — |
| loading Shows skeleton rows instead of data. | boolean | false |
| loadingRows Number of skeleton rows. | number | 4 |
| empty Replaces the default empty text. | React.ReactNode | — |
| tableClassName Classes for the inner <table>. className goes on the outer frame. | string | — |
| children Parts, for full control of the markup. | React.ReactNode | — |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<TableLabels> | — |
Also accepts every <table> attribute.
TableHeader
Also accepts every <thead> attribute.
TableBody
Also accepts every <tbody> attribute.
TableFooter
Also accepts every <tfoot> attribute.
TableRow
| Prop | Type | Default |
|---|---|---|
| tone State color: tinted background and a start-edge marker (paid, overdue, pending…). | "none" | TableTone | — |
| selected Draws the row as selected (tone tint + marker) and sets aria-selected. | boolean | — |
Also accepts every <tr> attribute.
TableHead
| Prop | Type | Default |
|---|---|---|
| align | "start" | "center" | "end" | — |
| numeric End-aligned tabular figures. | boolean | — |
| sortable Wraps the header text in a sort button with an arrow. | boolean | — |
| sort Current direction for this column, or false when unsorted. | TableSortDirection | false | — |
| onSort | () => void | — |
Also accepts every <th> attribute.
TableCell
| Prop | Type | Default |
|---|---|---|
| align | "start" | "center" | "end" | — |
| numeric | boolean | — |
| label Label shown before the value when rows are stacked as cards. | string | — |
| primary In stacked mode this cell is the card title. | boolean | — |
| hideWhenStacked Hidden when rows are stacked as cards. | boolean | — |
Also accepts every <td> attribute.
TableCaption
Also accepts every <caption> attribute.
Accessibility
| Tab | Moves through sort buttons in the header and, when onRowClick is set, through the rows. |
| Enter / Space | On a sort button: asc, desc, then unsorted. On a clickable row: calls onRowClick. |
| Arrow keys | When the table scrolls (maxHeight, or wider than its container), Tab focuses the scroll box and arrows scroll it. |
- It renders a real <table> with <th scope="col">, so screen readers announce headers with each cell. Pass aria-label or a caption to name it.
- Sortable headers are <button>s inside the <th>; the <th> gets aria-sort (ascending or descending) and the button reads ", sorted ascending" (labels.sortedAscending / sortedDescending).
- Selected rows get aria-selected. Row tones are color plus a start-edge bar; also show the state as text (a badge) so it does not rely on color alone.
- When stack is set, explicit table, rowgroup, row, columnheader and cell roles are added, because display:block can drop table semantics in some browsers. Stacked cells show their column label from data-label (columns: label or header text; parts: the label prop).
- Loading shows skeleton rows with a visually hidden "Loading" (labels.loading) and aria-busy on the body; the empty state text comes from empty or labels.empty.
- When the table can scroll (maxHeight, or wider than its container), the scroll box becomes a focusable role="region" named by the caption, the table's aria-label or labels.scrollRegion, so keyboard users can scroll it.
Motion
- fade: body rows fade in over 350 ms on mount. stagger: rows rise 6px and fade in over 400 ms, 35 ms apart, capped at the 13th row (455 ms). Remount (change key) to replay.
- hover lift moves the row up 1px over 180 ms; tint and accent change the background; the sort arrow turns 180° in 200 ms between ascending and descending.
- Reduced motion: entrances, transitions and the lift are off, and the skeleton shimmer stops.
- Pure CSS for every layer (one shared <style> tag). The only measurement is one ResizeObserver that checks horizontal overflow to make the scroll box focusable. Sorting with columns + data is a stable in-memory sort with Intl.Collator (numeric, case-insensitive).
- It renders every row. For hundreds of rows or more, use data-grid, which virtualizes.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Two APIs: columns + data (sorting, empty, loading, footer, stacking labels handled for you) or the parts (Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption). With children, columns and data are ignored.
- Column objects: { key, header, label?, accessor?, render?, sortValue?, sortable?, align?, numeric?, width?, footer?, primary?, hideWhenStacked?, className? }. key is also the property read from each row.
- With sort controlled (sort + onSortChange) the table does not sort data; sort it yourself. Uncontrolled (defaultSort) sorts for you; empty values stay last.
- className goes on the outer frame; tableClassName and other props go on the <table>. stickyHeader sticks to the page unless maxHeight makes the table its own scroll box.
- Row keys default to row.id, then the index; pass rowKey when ids are missing. Do not invent props like pagination or filters: use data-grid.
FAQ
How do I make it work on mobile?
Set stack="sm", "md" or "lg". Below 480, 640 or 768px of container width (a container query, so it reacts to the table's own width, not the viewport) each row becomes a card with a label before every value. Mark the title column primary and hide minor ones with hideWhenStacked.
Can I use it with server-side sorting?
Yes: pass sort and onSortChange, fetch sorted data in the handler, and pass the result as data.
Related
Virtualized data grid for 100k+ rows with typed columns (number, currency, percent, date, boolean, badge, avatar, actions) formatted by Intl, multi-sort, per-column filters and global search, resize, drag reorder, pin, hide, Shift-range selection with a bulk action bar, inline editing with sync or async validation, spreadsheet keyboard navigation, expandable rows, footer aggregates, CSV and real .xlsx export, loading, empty and error states, server mode with pagination, RTL and tones, densities and variants.
Outline, soft, ghost, pill, neo, minimal and carousel-dot pagination with a sliding active marker or press feedback, tones, sizes and shapes, plus first/last, range info, page-size select and jump-to-page.
One badge, every layer: solid, soft, outline, status dot, gradient, glass and neo in six tones, with pulse, shine, bounce-in and count-pop animations, icons, removable chips and clickable filters.
Six sizes and four shapes including squircle, status dots with pulse, solid, gradient and rotating story rings, image fade-in with name-based gradient initials, and groups that cap with +N and fan out on hover.
KPI card with seven variants, tones, shadows and sizes: count-up or rolling NumberFlow values, auto-toned delta badges (invertible for no-show rates), line, area or bar sparklines that draw in, icons, footers and a skeleton state.