Table

Free · MIT

Lined, 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.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/table.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Default app tablevariant="lined" (default)
Long rows that are hard to followvariant="striped"
Dense comparisons, spreadsheets-likevariant="bordered" density="compact"
Rows that feel like separate items (team, bookings)variant="cards" hover="lift"
Inside a card that already has a bordervariant="minimal"
Status per rowrowTone with columns + data, or tone on TableRow
Long tablesstickyHeader with maxHeight
Phonesstack="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

PropTypeDefault
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.
booleanfalse
maxHeight
Makes the table scroll inside a box of this height (px number or CSS length).
number | string—
captionReact.ReactNode—
captionSide"top" | "bottom""bottom"
columns
Columns for the data shorthand. With children, columns and data are ignored.
TableColumn<T>[]—
dataT[]—
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—
defaultSortTableSort | nullnull
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.
booleanfalse
loadingRows
Number of skeleton rows.
number4
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

PropTypeDefault
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

PropTypeDefault
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

PropTypeDefault
align"start" | "center" | "end"—
numericboolean—
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

TabMoves through sort buttons in the header and, when onRowClick is set, through the rows.
Enter / SpaceOn a sort button: asc, desc, then unsorted. On a clickable row: calls onRowClick.
Arrow keysWhen 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