Data
Table
A data table: a caption, column headers, and rows, from columns and rows or from parts. It scrolls sideways inside its own region on a phone, so the page never does. With rowHref each row is one link with one tab stop, named by its row header, and the buttons, links and inputs in its cells keep their own taps. A sortable column sorts the rows when its header is pressed, and reverses them on the next press. striped, dense and stickyHeader (inside a height you set) change how it reads. It does not page or filter.
Install
Once per app, with Tailwind CSS v4 and an @/* path alias (Ouro HX is built on Base UI, so --base base; skip -t in an existing app):
npx shadcn@latest init --base base --preset nova --yes --no-monoreponpx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13Then the component. From v0.8.0 it installs only the helpers it imports and never touches your stylesheet (Installing):
npx shadcn@latest add ourostack/ouro-ui/table#v1.2.13Or, with the @ouro namespace set up:
npx shadcn@latest add @ouro/ouro-base @ouro/tableIts tests, stories and Playwright spec are optional. They need Vitest 4.1.11 or later, and from v0.8.0 never change a version your app has (Testing):
npx shadcn@latest add ourostack/ouro-ui/table#v1.2.13 ourostack/ouro-ui/table-tests#v1.2.13import { Table } from "@/components/ouro/table/table";Paths use shadcn's default @/ alias; the CLI rewrites them to yours.
Example
<Table
caption="Recipes"
columns={[
{ key: "name", header: "Recipe" },
{ key: "time", header: "Time" },
{ key: "serves", header: "Serves", align: "end" },
]}
rows={recipes}
rowHref={(recipe) => `/recipes/${recipe.id}`}
/>
<Table
caption="Orders"
columns={[
{ key: "customer", header: "Customer", sortable: true },
{ key: "placed", header: "Placed", cell: (order) => formatDate(order.placed), sortValue: (order) => order.placed },
{ key: "total", header: "Total", align: "end", sortable: true },
]}
rows={orders}
defaultSort={{ key: "placed", direction: "descending" }}
/>
<Table caption="Pantry" hideCaption>
<TableHeader>
<TableRow><TableHead>Item</TableHead><TableHead align="end">Qty</TableHead></TableRow>
</TableHeader>
<TableBody>
<TableRow><TableCell rowHeader>Flour</TableCell><TableCell align="end">2 kg</TableCell></TableRow>
</TableBody>
</Table>Usage
To sort on a server or by more than one column, control sort and pass the rows in that order.
Use instead when
- Use
DescriptionListinstead for one thing's details as label and value pairs, andItemGroupfor a list of rows that need no columns, which reads better on a phone.
Props
The props the modes share. Type: TableProps<Row>.
caption(required)- Type:
string - What the table shows: "Recipes", "Order history". It names the table and its scrolling region for screen readers, and shows above it unless
hideCaption. className- Type:
string - Layout: margins, width, and the height the table scrolls inside (
max-h-96). Appearance classes warn in development; put the Table inside aCardfor a frame. dense- Type:
boolean - Tighter cells, for many rows. A row is still 44px tall on a touch screen when it is a link.
hideCaption- Type:
boolean - Hides the caption visually and keeps it as the table's name, when a heading already shows it.
ref- Type:
Ref<HTMLTableElement> - A ref to the
<table>element. sortedLabel- Type:
((header: string, direction: TableSortDirection) => string) - Default: (header, direction) =>
Sorted by ${header}, ${direction} - What screen readers hear after a header sorts the rows, from the column's header and the new direction. Pass it to translate the message.
stickyHeader- Type:
boolean - Keeps the header row at the top while the rows scroll under it. The table scrolls inside its own height, so give it one:
className="max-h-96". It keeps working while the table also scrolls sideways. striped- Type:
boolean - Shades every other body row, for long rows that are hard to follow across.
style- Type:
CSSProperties
Modes
TableProps<Row> is a union: pass the props of exactly one mode. Mixing them is a type error.
With children
children(required)- Type:
ReactNode - The parts:
TableHeader,TableBodyandTableFooter, withTableRow,TableHeadandTableCellin them.
Not in this mode (typed never):
columns: Only without parts.defaultSort: Only without parts. Give the sorted column's header<TableHead sorted="ascending">.empty: Only without parts.externalLabel: Only without parts. Give a row<TableRow externalLabel="…">.onSortChange: Only without parts. Give each header<TableHead onSort={…}>.rowExternal: Only without parts. Give a row<TableRow href="…" external>.rowHref: Only without parts. Give a linked row<TableRow href="…">.rowKey: Only without parts.rows: Only without parts.sort: Only without parts. Give the sorted column's header<TableHead sorted="ascending">.
With columns and rows and sort and defaultSort and onSortChange and rowKey and rowHref and rowExternal and externalLabel and empty
columns(required)- Type:
readonly TableColumn<NoInfer<Row>>[] - The columns, in order: each one's
header,keyand optionalcell. defaultSort- Type:
TableSort - The column the rows are sorted by at first. Table then sorts the rows itself as the headers are pressed.
empty- Type:
ReactNode - Default:
"Nothing here yet." - What the body shows when there are no rows, in one cell across every column.
externalLabel- Type:
string - Default:
"(opens in a new tab)" - What screen readers hear after a row link's name with
rowExternal. onSortChange- Type:
((sort: { key: string; direction: TableSortDirection }) => void) - Called with the new sort when a header is pressed, with
sortor without it. rowExternal- Type:
boolean - Opens every row link in a new tab, safely, and tells screen readers so with
externalLabel. rowHref- Type:
TableKeysOf<Row, string | undefined> | ((row: Row) => string | undefined) - Makes each row one link, with one tab stop, named by its row header cell: a function that returns the row's path (or
undefinedfor a row that is not a link), or the name of a field that holds it (rowHref="href"), which a Server Component can pass. Buttons, links and inputs in the cells stay their own tab stops and take their own taps. On a touch screen a linked row is at least 44px tall. rowKey- Type:
TableKeysOf<Row, string | number> | ((row: Row, index: number) => Key) - Each row's React key: the name of a field (
rowKey="slug") or a function. Without it, a row'sidfield, or else its position. rows(required)- Type:
readonly Row[] - The rows, in the order to show them until a
sortablecolumn sorts them. Table does not page them. sort- Type:
TableSort | null - The sorted column, controlled: Table marks its header and shows the rows in the order you pass them, so you sort them (on a server, say), and
onSortChangetells you which sort to show next. Passnullwhile nothing is sorted. To let Table sort the rows itself, usedefaultSortinstead.
Not in this mode (typed never):
children: Only with parts. Pass columns and rows, or parts as children.
Native attributes
Also accepts the attributes in React's HTMLAttributes (id, aria-*, event handlers), passed to the rendered element, except those named on this page.
Don't use
These are not props. Passing one fails the type check with the fix in the error, and in development a console message names the fix.
data: Not a prop. Ouro UI usesrows:<Table rows={recipes} columns={…}>.onRowClick: Not a prop. A row opens a link withrowHref:<Table rowHref={(row) =>/recipes/${row.id}}>.sortable: Not a prop. A column sorts withsortable:{ key: "name", header: "Recipe", sortable: true }.title: Not a prop. Ouro UI usescaption:<Table caption="Recipes">.
className is for layout only
Pass margins, width and grid placement (mt-4, w-full, col-span-2). Your classes always win over Ouro UI's: Table merges className last with cn (tailwind-merge), so a class that conflicts with its own replaces it.layout-classes
Appearance comes from props, not classes. In development, a class that sets appearance logs one warning per component instance, naming the prop to use instead. A class sets appearance when, after its variants (hover:, sm:) and any ! are removed, it starts with bg-, text-, border-, rounded-, shadow-, font-, ring-, from-, via-, to-, fill- or stroke-, or is border, rounded, shadow or ring, or has a dark: variant. Text wrapping, overflow and table layout classes (text-wrap, text-ellipsis, border-collapse) are layout and do not warn.
Alignment classes (text-center) warn too: set alignment on a parent element.
Parts
When these props cannot express your layout, build it from the parts in @/components/ouro/table/table-parts. The nesting they need follows.
import {
TableBody,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRoot,
TableRow,
} from "@/components/ouro/table/table-parts";Required nesting
<TableRoot caption="Pantry">
<TableHeader>
<TableRow>
<TableHead>Item</TableHead>
<TableHead align="end">Qty</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow href="/pantry/flour">
<TableCell rowHeader>Flour</TableCell>
<TableCell align="end">2 kg</TableCell>
</TableRow>
</TableBody>
</TableRoot>Each part checks that it sits inside <TableRoot>: outside it, a part throws in development with a message that shows this JSX shape.table-nesting
TableBody- What it is: The table's body rows (
<tbody>). TableCell- What it is: A cell (
<td>), or the row's header withrowHeader(<th scope="row">). TableFooter- What it is: Summary rows under the body (
<tfoot>), such as a total, with a line above them. TableHead- What it is: A column header (
<th scope="col">) in aTableHeaderrow. WithonSortit is a button that sorts the rows by its column, andsortedmarks the column they are sorted by. TableHeader- What it is: The table's header rows (
<thead>), holding aTableRowofTableHeadcells. WithstickyHeaderon the Table, they stay at the top while the rows scroll. TableRoot- What it is: The root of a table built from parts, with its caption and its scrolling region.
Tabletakes the same parts as children (<Table caption="Pantry">…</Table>), so use it instead unless you are composing parts of your own;Tablealso builds the whole table fromcolumnsandrows. TableRow- What it is: One row (
<tr>). Withhrefa body row is one link, held by its row header cell.
Types
TableColumn
One column of a Table: its header, and where its cells come from. key names a field of the row whose value is shown as it is (text, a number, an element), or, with cell, any name, and cell renders each row's cell.
type TableColumn<Row> = TableColumnBase & {
/**
* What the column sorts by, when its cells show something else (a `cell` renderer, a
* formatted date): `sortValue: (row) => row.added`. It makes the column `sortable`.
*/
sortValue?: ((row: Row) => TableSortValue) | undefined;
} & (
| {
/**
* The field of each row to show, and the column's React key.
*/
key: TableKeysOf<Row, ReactNode>;
/** Renders the row's cell instead of the field's value. */
cell?: ((row: Row, index: number) => ReactNode) | undefined;
}
| {
/** The column's name for React; with `cell`, it need not be a field of the row. */
key: string;
/**
* Renders the row's cell: a badge, a formatted date, a button. From a Server
* Component, put the element in the row's field instead, because a function can't
* cross to the client.
*/
cell: (row: Row, index: number) => ReactNode;
}
);TableColumnBase
What every column takes, whichever way its cells are filled.
interface TableColumnBase {
/**
* The column's name, in its header cell. Keep it even for a column of buttons, and hide
* it with `hideHeader`: screen readers read it with every cell.
*/
header: string;
/** Hides the header visually and keeps it for screen readers, for a column of actions. */
hideHeader?: boolean | undefined;
/**
* How the column lines up: `end` for numbers and amounts, `center` for a status mark.
* @default "start"
*/
align?: TableAlign | (string & { 'Use "start", "center" or "end": right is "end"': never }) | undefined;
/**
* Makes this column the rows' headers (`<th scope="row">`), what each row is. With
* `rowHref` its cell holds the row's link and names it. Without one, the first column with
* no `cell` renderer is, or else the first column.
*/
rowHeader?: boolean | undefined;
/**
* Lets long text wrap onto more lines, at least 192px wide, instead of widening the table.
*/
wrap?: boolean | undefined;
/** Layout only, on the header cell: a width (`w-32`). */
className?: string | undefined;
/**
* Makes the column's header a button that sorts the rows by it; pressing it again reverses
* them. Table sorts by the row's `key` field, or by `sortValue`: numbers and dates as
* numbers and dates, text in the reader's language with the numbers in it in order ("Step 2"
* before "Step 10"), and rows with an empty value last. Rows with equal values keep their order.
*/
sortable?: boolean | undefined;
/** Not a prop. Ouro UI uses `sortable`: `{ key: "name", header: "Recipe", sortable: true }`. */
enableSorting?: { "Use sortable: true": never };
/** Not a prop. Ouro UI uses `key`: `{ key: "name", header: "Recipe" }`. */
accessorKey?: { 'Use key: "name"': never };
/** Not a prop. Ouro UI uses `header`: `{ key: "name", header: "Recipe" }`. */
title?: { 'Use header: "Recipe"': never };
/** Not a prop. Ouro UI uses `header`: `{ key: "name", header: "Recipe" }`. */
label?: { 'Use header: "Recipe"': never };
/** Not a prop. Ouro UI uses `cell`: `{ key: "save", header: "Save", cell: (row) => <Button>Save</Button> }`. */
render?: { "Use cell: (row) => ...": never };
}TableKeysOf
The keys of Row whose values are assignable to Value.
type TableKeysOf<Row, Value> = { [K in keyof Row]-?: Row[K] extends Value ? K : never }[keyof Row] & string;TableSort
Which column the rows are sorted by, and which way: what sort, defaultSort and onSortChange take.
interface TableSort {
/** The sorted column's `key`. */
key: string;
/** `"ascending"`: A to Z, low to high, old to new. `"descending"`: the other way. */
direction: TableSortDirection | (string & { 'Use "ascending" or "descending": asc is "ascending"': never });
}TableSortValue
What a column sorts by: text, a number, a date or a boolean. An empty value sorts last.
type TableSortValue = string | number | Date | boolean | null | undefined;Claim sources
- layout-classes: test:registry/ouro/components/ouro/table/table.test.tsx::Table > passes attributes to the root, the ref to the table, and lets layout classes win
verify:testExists(registry/ouro/components/ouro/table/table.test.tsx::Table > passes attributes to the root, the ref to the table, and lets layout classes win) - field-nesting: test:registry/ouro/components/ouro/field/field.test.tsx::Field parts > %s throws outside FieldRoot with the JSX shape
verify:testExists(registry/ouro/components/ouro/field/field.test.tsx::Field parts > %s throws outside FieldRoot with the JSX shape) - select-nesting: test:registry/ouro/components/ouro/select/select.test.tsx::Select development messages > throws with the JSX shape when a part is outside Select
verify:testExists(registry/ouro/components/ouro/select/select.test.tsx::Select development messages > throws with the JSX shape when a part is outside Select) - dialog-nesting: test:registry/ouro/components/ouro/dialog/dialog.test.tsx::Dialog parts > %s outside DialogRoot throws with the correct JSX shape
verify:testExists(registry/ouro/components/ouro/dialog/dialog.test.tsx::Dialog parts > %s outside DialogRoot throws with the correct JSX shape) - popover-nesting: test:registry/ouro/components/ouro/popover/popover.test.tsx::Popover parts > %s outside PopoverRoot throws with the correct JSX shape
verify:testExists(registry/ouro/components/ouro/popover/popover.test.tsx::Popover parts > %s outside PopoverRoot throws with the correct JSX shape) - sheet-nesting: test:registry/ouro/components/ouro/sheet/sheet.test.tsx::Sheet parts > %s outside SheetRoot throws with the correct JSX shape
verify:testExists(registry/ouro/components/ouro/sheet/sheet.test.tsx::Sheet parts > %s outside SheetRoot throws with the correct JSX shape) - checklist-nesting: test:registry/ouro/components/ouro/checklist/checklist.test.tsx::Checklist parts > throw in development outside a ChecklistRoot, with the JSX they belong in
verify:testExists(registry/ouro/components/ouro/checklist/checklist.test.tsx::Checklist parts > throw in development outside a ChecklistRoot, with the JSX they belong in) - tabs-nesting: test:registry/ouro/components/ouro/tabs/tabs.test.tsx::Tabs parts > throws for %s outside TabsRoot with the JSX it belongs in
verify:testExists(registry/ouro/components/ouro/tabs/tabs.test.tsx::Tabs parts > throws for %s outside TabsRoot with the JSX it belongs in) - table-nesting: test:registry/ouro/components/ouro/table/table.test.tsx::Table parts > throws in development when %s is outside its parent
verify:testExists(registry/ouro/components/ouro/table/table.test.tsx::Table parts > throws in development when %s is outside its parent)