Table
A styled, semantic data table — primitives plus smart parts for sorting, row selection, sticky/pinned columns, column resize, empty/loading states, and pagination.
@garn/ui/tableDefault
An invoices table with a caption, header, and right-aligned amounts.
| Invoice | Status | Method | Amount |
|---|---|---|---|
| INV001 | Paid | Credit Card | $250.00 |
| INV002 | Pending | PayPal | $150.00 |
| INV003 | Unpaid | Bank Transfer | $350.00 |
Empty
A full-width empty state nesting the Empty component.
| Invoice | Status | Method | Amount |
|---|---|---|---|
No invoices yet Invoices you create will show up here. | |||
Loading
Skeleton-row loading state (aria-busy).
| Name | Role | Status |
|---|
Data Table
The full composition: sortable headers + row selection (→ selection bar) + pagination.
| Status | |||
|---|---|---|---|
| Ada Lovelace | ada@vertex.io | Active | |
| Alan Turing | alan@vertex.io | Invited | |
| Edsger Dijkstra | edsger@vertex.io | Suspended |
Installation
garn is copy-in — the garn CLI writes this component's source into your project, so you own and can edit it. It resolves any base files and installs the npm dependencies below for you.
npx garn-ui add table- Registry
table- Source
- table.tsx
- Deps
lucide-react- Registry deps
utilsuse-controllable-stateuse-isomorphic-layout-effectuse-merged-refbuttoncheckboxpaginationselectskeletonsortable
Import
import { Table } from "@garn/ui/table";Props
Generated from the component's TypeScript types.
containerProps- Description
- Props for the scrolling wrapper — set `className="max-h-*"` here so a `stickyHeader` has a bounded box to stick within.
- Type
React.DetailedHTMLProps<React.HTMLAttributes<HTMLDivElement>, HTMLDivElement>
density- Description
- Local density override; omit to inherit the ambient `[data-density]`.
- Type
"compact" | "spacious"
layout- Description
- Column-sizing model. `"fixed"` makes a `TableHead`'s `width` govern its column (content truncates instead of widening it) — the substrate column resize needs. Omit for the default content-driven `"auto"` layout.
- Type
"auto" | "fixed"
stickyFooter- Description
- Stick the footer to the bottom of the scroll container on vertical scroll — for a summary / totals `TableFooter` row that stays visible over a scrolling body. Needs a height-bounded `containerProps.className` to have room to stick, same as `stickyHeader`.
- Type
boolean
stickyHeader- Description
- Stick the header to the top of the scroll container on vertical scroll.
- Type
boolean
Plus 289 inherited native <table> attributes.
TableBody
onReorder- Description
- Fires on drop with the next key order. Apply it to your data.
- Type
(order: string[]) => void
order- Description
- The body row keys in visual order — the array driving your row `.map`. Required when `reorderable`.
- Type
string[]
reorderable- Description
- Enable drag-to-reorder for the body rows. Each reorderable {@link TableRow} needs a `value`, and a {@link TableDragHandle} in a leading cell grabs it. Powered by garn's `sortable` primitive — pointer + keyboard + screen-reader announcements come free. The order is **controlled**: pass `order` and apply the next order in `onReorder` (garn reorders the keys, never your row data).
- Type
boolean
Plus 280 inherited native HTML attributes.
TableRow
value- Description
- The row's stable key when the owning {@link TableBody} is `reorderable` — the same id you keyed the `.map` and `order` array by. Wires the row into the drag engine and lets a nested {@link TableDragHandle} grab it. Ignored otherwise.
- Type
string
Plus 280 inherited native HTML attributes.
TableHead
maxWidth- Description
- No description.
- Type
number
minWidth- Description
- Resize bounds (px). `minWidth` defaults to 80 once resizable.
- Type
number
onResize- Description
- Live width during a drag (per-pixel). Usually omitted — the DOM updates imperatively, so let `onResizeEnd` commit the final value.
- Type
(width: number) => void
onResizeEnd- Description
- Committed width on pointer release, each keyboard step, and double-click auto-fit. Its presence makes the header resizable (renders a {@link TableColumnResizer} grip).
- Type
(width: number) => void
onSort- Description
- Fired when the sort control is activated (click / Enter / Space).
- Type
() => void
pin- Description
- Pin this header cell to the start/end edge on horizontal scroll.
- Type
"end" | "start"
resizeLabel- Description
- Accessible name for the resize grip when the header label isn't plain text (defaults to the string children, else "column").
- Type
string
sortDirection- Description
- When set, the header becomes sortable: it owns `aria-sort` + `data-sort` and wraps its label in a {@link TableSortButton}. The consumer owns the comparator/data and the tri-state cycle (ascending → descending → none); the header reflects the current direction and fires `onSort`.
- Type
TableSortDirection
width- Description
- Controlled column width (px). Needs `Table layout="fixed"` to govern the column; pair with `onResizeEnd` (or the `useColumnResize` hook) to resize.
- Type
number
Plus 286 inherited native HTML attributes.
TableCell
pin- Description
- Pin this cell to the start/end edge on horizontal scroll.
- Type
"end" | "start"
Plus 289 inherited native HTML attributes.
TableSortButton
sortDirection- Description
- No description.
- Type
TableSortDirection
Plus 290 inherited native HTML attributes.
TableColumnResizer
disabled- Description
- No description.
- Type
boolean
max- Description
- No description.
- Type
number
min- Description
- Resize bounds (px).
- Type
number
onResize- Description
- Live width while dragging. Also receives a committed keyboard-step / auto-fit width **when `onResizeEnd` is not wired**, so an `onResize`-only consumer still hears those (a pointer drag already streams the final width here). Wire `onResizeEnd` for a dedicated commit callback.
- Type
(width: number) => void
onResizeEnd- Description
- Committed width on release, each keyboard step, and double-click auto-fit.
- Type
(width: number) => void
step- Description
- Keyboard step (px); Shift / PageUp·PageDown use a larger step.
- Type
number
value- Description
- Current committed width (px) — drives `aria-valuenow`; measured from the host header if omitted.
- Type
number
Plus 280 inherited native HTML attributes.
TableSelectionCell
checkedRequired- Description
- No description.
- Type
boolean
label- Description
- Row label for a distinct accessible name — the checkbox is named "Select {label}" (e.g. the row's primary text), so screen-reader users don't hear N identical "Select row" controls. An explicit `aria-label` still wins; omitting both keeps the generic "Select row". Mirrors {@link TableExpandToggle}.
- Type
string
onCheckedChange- Description
- No description.
- Type
(checked: boolean) => void
onRangeChange- Description
- Called instead of {@link onCheckedChange} when the checkbox is activated with **Shift held** — wire it to a range selector (e.g. `useTableSelection`'s `toggleRange(id)`) for Gmail/Finder contiguous selection. Works for both Shift+click and Shift+Space (keyboard-accessible range select); the plain toggle remains the modifier-free path.
- Type
(checked: boolean) => void
Plus 289 inherited native HTML attributes.
TableSelectAllCell
checkedRequired- Description
- No description.
- Type
"indeterminate" | boolean
onCheckedChange- Description
- No description.
- Type
(checked: boolean) => void
Plus 292 inherited native HTML attributes.
TableEmpty
colSpanRequired- Description
- Number of columns to span — usually your column count.
- Type
number
Plus 288 inherited native HTML attributes.
TableSkeletonRows
cellClassName- Description
- Class applied to each cell's Skeleton bar.
- Type
string
columnsRequired- Description
- How many cells per row — match your column count.
- Type
number
rows- Description
- How many placeholder rows to render.
- Type
number
Plus 280 inherited native HTML attributes.
TablePagination
disabled- Description
- No description.
- Type
boolean
onPageChangeRequired- Description
- No description.
- Type
(page: number) => void
onPageSizeChange- Description
- No description.
- Type
(pageSize: number) => void
pageRequired- Description
- Current page, 1-based.
- Type
number
pageCountRequired- Description
- Total number of pages.
- Type
number
pageSize- Description
- Current rows-per-page; enables the rows-per-page Select with `pageSizeOptions`.
- Type
number
pageSizeOptions- Description
- No description.
- Type
number[]
total- Description
- Total row count — switches the indicator to "N–M of T".
- Type
number
Plus 279 inherited native HTML attributes.
Styling
Target these data-slots and remap these tokens to restyle without forking the component.
[data-slot="table"][data-slot="table-container"][data-slot="table-resize-guide"]| Property | Token | Tier |
|---|---|---|
cellHeight | --garn-control-h-md | semantic · air |
pinnedSurface | --garn-background | semantic |
seam | --garn-border | semantic |
seamShadowStart | --garn-shadow-pin-start | semantic |
seamShadowEnd | --garn-shadow-pin-end | semantic |
seamShadowHeader | --garn-shadow-pin-bottom | semantic |
resizeGuide | --garn-brand-solid | semantic |
Overview
Table presents structured records in rows and columns where alignment and scanning down a column matter — invoices, users, metrics. garn keeps the markup honest (real <table>/<thead>/<tbody>/<th>/<td> via Table, TableHeader, TableBody, TableRow, TableHead, TableCell) and layers the data-grid behaviors on as opt-in smart parts rather than a monolithic component you configure with a giant prop object.
Each capability is a part you add only when you need it: a sortable header (TableHead with sortDirection + onSort, which wires aria-sort and renders a TableSortButton), row selection (TableSelectionCell + TableSelectAllCell, composing Checkbox), frozen columns (pin="start|end"), keyboard-resizable columns (onResizeEnd + layout="fixed"), a sticky header, and the TableEmpty / TableSkeletonRows / TablePagination state parts. For a read-only set of field→value pairs about one entity, that's not a table — use DataList.
Guidelines
Give the table an accessible name. Add a TableCaption (it can be visually quiet) so screen-reader users know what the grid contains. When you make a header sortable, drive it through sortDirection/onSort so aria-sort reflects the live state — don't fake the chevron with a plain icon.
Add smart parts only as the data demands them. A 10-row reference table needs none of pagination, sticky headers, or resizing; reaching for them by default adds weight and interaction cost. Pin columns sparingly — freezing an identifier column helps on wide tables, but pinning several defeats the horizontal scroll it's meant to tame.
Always render the empty and loading states. A table that collapses to a bare header when there's no data reads as broken; drop in TableEmpty (optionally nesting the rich empty component) and TableSkeletonRows while fetching, and set aria-busy on the region. Keep numeric columns right-aligned so digits line up for comparison.
Best practices
- Name the table with a TableCaption; use TableHead for column headers.
- Right-align numeric columns and keep units consistent.
- Own the sort comparator + page slicing yourself (or via a headless table lib) — the parts are controlled and own only the aria-sort / pagination UI.
- Use
useTableSelection({ rowIds })for select-all + indeterminate, and feed its{ count, clear }straight into app-shell's AppShellSelectionBar. - For wide tables, pin the selection column
startand the actions columnend, and setstickyHeaderwithcontainerProps={{ className: 'max-h-*' }}. - To resize columns, set
Table layout="fixed"and drive widths withuseColumnResize(spreadcolumn(id)onto each TableHead) so a header's width governs its column and content truncates instead of fighting the drag.
- Don't use a table for visual layout.
- Don't omit column headers — screen readers rely on them to announce cells.
- Don't reimplement the sort/filter/page engine in garn — drive it yourself or with a headless lib; the parts stay controlled.
Content guidelines
- Write concise, consistent column headers that name the data.
- Keep cell content scannable; right-align numbers and use consistent units and formats.
Accessibility
- Role
table- Focus
- Sortable-header buttons and selection checkboxes keep their focus-visible rings.
Keyboard
| Enter | Activate a sortable column header (cycle the sort direction). |
| Space | Activate a sortable header; toggle a row checkbox or the select-all checkbox. |
| ArrowLeftArrowRight | On a focused column resizer, shrink / grow the column by one step (hold Shift, or use PageUp/PageDown, for a larger step). |
| HomeEnd | On a focused column resizer, jump the column to its minimum / maximum width. |
- • Built on real <table>/<thead>/<th>/<tbody>/<td> — the semantics come from the elements; keep them intact (role=table, not grid).
- • Name the table with a TableCaption (or aria-label/aria-labelledby).
- • Use TableHead (<th>) for column headers; add `scope="row"` to a row's leading <th> when rows have headers.
- • Sortable headers set `aria-sort` (ascending | descending | none) on the <th> and render a native <button>; only one column carries a non-none aria-sort — ARIA has no multi-column sort.
- • Give each TableSelectionCell checkbox an accessible name (`aria-label`, e.g. "Select <row>"); the select-all goes `aria-checked="mixed"` when partially selected.
- • TableSkeletonRows are aria-hidden — set `aria-busy` on the table/region while loading so the state is announced, not read as data.
- • Sticky header and pinned columns are presentational; horizontal scroll (Reflow) is preserved, never trapped.
- • A resizable TableHead renders a role="slider" resize grip (aria-valuemin/now/max = min/current/max width; ArrowLeft/Right resize, Home/End jump to the bounds, double-click auto-fits to content). The header pins its own accessible name so the slider's value doesn't bleed into the column-header name — pass `resizeLabel` (or `aria-label`) for a non-text header.
- Status
- Stable
- Version
v0.3.0- Introduced
v0.1.0
garn doesn't yet record a per-release version history. This reflects the component's current lifecycle state; full changelog entries will appear once the metadata pipeline emits a lifecycle.history field.