Skip to content

Table

A styled, semantic data table — primitives plus smart parts for sorting, row selection, sticky/pinned columns, column resize, empty/loading states, and pagination.

Stablev0.4.2added in v0.1.0@garn/ui/table
On this page

Default

An invoices table with a caption, header, and right-aligned amounts.

Empty

A full-width empty state nesting the Empty component.

Loading

Skeleton-row loading state (aria-busy).

Data Table

The full composition: sortable headers + row selection (→ selection bar) + pagination.

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.

terminal
npx garn-ui add table
First time? Set up garn in your project
Registry
table
Deps
lucide-react
Registry deps
utilsuse-controllable-stateuse-isomorphic-layout-effectuse-merged-refbuttoncheckboxpaginationselectskeletonsortable

Import

import { Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption, TableSortButton, TableColumnResizer, TableSelectionCell, TableSelectAllCell, TableEmpty, TableSkeletonRows, TablePagination } from "@garn/ui/table";

Anatomy

The parts this component is built from — compose them to assemble it.

TableRequired[data-slot="table"]

The <table> (wrapped in a scrolling container). Props: density (local override), stickyHeader, layout ("fixed" so a header's width governs its column — the resize substrate), containerProps.

TableHeader[data-slot="table-header"]

The <thead> column-header group.

TableBodyRequired[data-slot="table-body"]

The <tbody> data rows.

TableFooter[data-slot="table-footer"]

The <tfoot> summary row (muted, medium weight).

TableRow[data-slot="table-row"]

A <tr>; hover highlight + data-state="selected" styling.

TableHead[data-slot="table-head"]

A column header cell (<th>). sortDirection + onSort make it a sortable header (auto aria-sort + a sort button); pin="start|end" freezes it on horizontal scroll; width/minWidth/maxWidth + onResize/onResizeEnd make it resizable (renders a TableColumnResizer grip — needs Table layout="fixed"). A header can be both sortable and resizable.

TableCell[data-slot="table-cell"]

A data cell (<td>). pin="start|end" freezes the column on horizontal scroll.

TableCaption[data-slot="table-caption"]

The table's accessible name/summary (<caption>).

TableSortButton[data-slot="table-sort-button"]

The in-cell sort control — a ghost Button with a direction-aware chevron. TableHead renders it for you from sortDirection; exported for bespoke headers. aria-sort on the <th> carries the state.

TableColumnResizer[data-slot="table-column-resizer"]

The column-resize grip — a focusable role="slider" on the header's trailing edge. Drag (imperative CSS-var, no per-pixel re-render), Arrow/Home/End/Shift/Page to resize via keyboard, double-click to auto-fit to content; commits via onResizeEnd. TableHead renders it for you from onResizeEnd; exported for bespoke headers.

TableSelectionCell[data-slot="table-selection-cell"]

A per-row checkbox cell (composes Checkbox). Controlled checked/onCheckedChange; needs an accessible aria-label. Pair with data-state="selected" on the row.

TableSelectAllCell[data-slot="table-select-all"]

The header select-all checkbox (a TableHead + Checkbox). Pass checked="indeterminate" for the some-but-not-all state (renders a dash; aria-checked="mixed").

TableEmpty[data-slot="table-empty"]

A full-width empty-state row (<tr><td colSpan>). Drop in a message, or nest the empty component for the rich icon/title version.

TableSkeletonRows[data-slot="table-skeleton-row"]

Loading placeholder rows (compose Skeleton). rows × columns; aria-hidden so screen readers skip them — set aria-busy on the region.

TablePagination[data-slot="table-pagination"]

The data-table footer — composes pagination (the nav landmark) + an optional rows-per-page select + an "N–M of T" indicator. Controlled page/pageCount/onPageChange.

Props

The component's public props and their 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
Upper resize bound (px); unbounded when omitted.
Type
number
minWidth
Description
Lower resize bound (px).
Type
number
Default
80
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
Current sort direction — picks the trailing glyph and `data-sort`. The host `TableHead` carries the `aria-sort` state.
Type
TableSortDirection
Default
"none"

Plus 290 inherited native HTML attributes.

TableColumnResizer

disabled
Description
Whether resizing is disabled — the grip stays rendered but inert (`aria-disabled`).
Type
boolean
max
Description
Upper resize bound (px); unbounded when omitted.
Type
number
min
Description
Lower resize bound (px).
Type
number
Default
80
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
Default
16
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
Whether this row is selected.
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
Fires with the next checked state on a plain (modifier-free) toggle.
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
The all-rows state; `"indeterminate"` when some but not all rows are selected.
Type
"indeterminate" | boolean
onCheckedChange
Description
Fires `true` to select all rows on the page, `false` to clear.
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
Whether the whole footer is inert (e.g. while a page fetch is in flight).
Type
boolean
onPageChangeRequired
Description
Fires with the next 1-based page on prev/next/first/last.
Type
(page: number) => void
onPageSizeChange
Description
Fires with the chosen rows-per-page.
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
Choices for the rows-per-page Select (e.g. `[10, 25, 50]`).
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 states, and remap these tokens, to restyle without forking the component.

[data-slot="table"][data-slot="table-container"][data-slot="table-resize-guide"]

States

SelectorState
tr:hoverrow-hover
tr[data-state=selected]row-selected
th[aria-sort]column-sorted
[data-pin=start], [data-pin=end]pinned
table[data-sticky-header]sticky-header
[data-slot=table-container][data-density]density
th[data-resizable]resizable
[data-slot=table-column-resizer][data-resizing]resizing
table[data-layout]layout
PropertyTokenTier
cellHeight--garn-control-h-mdsemantic · air
pinnedSurface--garn-backgroundsemantic
seam--garn-bordersemantic
seamShadowStart--garn-shadow-pin-startsemantic
seamShadowEnd--garn-shadow-pin-endsemantic
seamShadowHeader--garn-shadow-pin-bottomsemantic
resizeGuide--garn-brand-solidsemantic

When to use

Present structured data in rows and columns where alignment and comparison matter — invoices, users, metrics — with sorting, selection, sticky/pinned columns, and pagination via the smart parts.

Reach for something else when

  • Page layout (use CSS grid/flex, never a table)
  • a simple one-dimensional list (use a list)
  • a few key/value pairs (use a description list).

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

Do
  • 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 start and the actions column end, and set stickyHeader with containerProps={{ className: 'max-h-*' }}.
  • To resize columns, set Table layout="fixed" and drive widths with useColumnResize (spread column(id) onto each TableHead) so a header's width governs its column and content truncates instead of fighting the drag.
Don't
  • 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.

Troubleshooting

Screen readers announce cells with no column context.

Cause. Column headers weren't marked up as TableHead (<th>).

Fix. Put column headers in a TableHeader → TableRow → TableHead, and caption the table.

Reaching for a table to lay out a page/cards.

Cause. Tables are for tabular data, not layout.

Fix. Use CSS grid/flex for layout; reserve Table for rows × columns of data.

Sorting more than one column at once, or aria-sort on several headers.

Cause. ARIA has no multi-column sort; only one column may carry a non-none aria-sort.

Fix. Reflect ascending/descending on the active column only; others stay sortDirection="none".

The sticky header doesn't stick.

Cause. stickyHeader needs a bounded scroll box, and the Table owns the scroll wrapper.

Fix. Pass containerProps={{ className: 'max-h-96' }} (or similar) so the wrapper can scroll.

The bulk-action bar never appears on selection.

Cause. Selection state wasn't lifted into the bar.

Fix. Drive selection with useTableSelection and render <AppShellSelectionBar count={sel.count} onClear={sel.clear}> when count > 0.

Dragging the resize grip does nothing, or the column snaps back.

Cause. A header's width only governs its column under fixed table-layout, and the width must be controlled.

Fix. Set Table layout="fixed" and commit width via onResizeEnd (or useColumnResize); auto layout lets cell content fight the drag.

A resizable column header is announced with a trailing number (e.g. "Name 200").

Cause. The resize grip is a role="slider" child, so its value leaks into the header's name-from-content.

Fix. A resizable TableHead auto-pins its aria-label to a string label; for a non-text header, pass resizeLabel (or aria-label).

Accessibility

Role
table
Focus
Sortable-header buttons and selection checkboxes keep their focus-visible rings.

Keyboard

EnterActivate a sortable column header (cycle the sort direction).
SpaceActivate a sortable header; toggle a row checkbox or the select-all checkbox.
ArrowLeftArrowRightOn a focused column resizer, shrink / grow the column by one step (hold Shift, or use PageUp/PageDown, for a larger step).
HomeEndOn a focused column resizer, jump the column to its minimum / maximum width.
A1.3.1Info and RelationshipsAA1.4.10ReflowA2.1.1KeyboardA4.1.2Name, Role, Value
  • 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.