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-monorepo
npx shadcn@latest add ourostack/ouro-ui/ouro-base#v1.2.13

Then 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.13

Or, with the @ouro namespace set up:

npx shadcn@latest add @ouro/ouro-base @ouro/table

Its 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.13
import { 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 DescriptionList instead for one thing's details as label and value pairs, and ItemGroup for 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 a Card for 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, TableBody and TableFooter, with TableRow, TableHead and TableCell in 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, key and optional cell.
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 sort or 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 undefined for 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's id field, or else its position.
rows (required)
Type: readonly Row[]
The rows, in the order to show them until a sortable column 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 onSortChange tells you which sort to show next. Pass null while nothing is sorted. To let Table sort the rows itself, use defaultSort instead.

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 uses rows: <Table rows={recipes} columns={…}>.
  • onRowClick: Not a prop. A row opens a link with rowHref: <Table rowHref={(row) => /recipes/${row.id}}>.
  • sortable: Not a prop. A column sorts with sortable: { key: "name", header: "Recipe", sortable: true }.
  • title: Not a prop. Ouro UI uses caption: <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 with rowHeader (<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 a TableHeader row. With onSort it is a button that sorts the rows by its column, and sorted marks the column they are sorted by.
TableHeader
What it is: The table's header rows (<thead>), holding a TableRow of TableHead cells. With stickyHeader on 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. Table takes the same parts as children (<Table caption="Pantry">…</Table>), so use it instead unless you are composing parts of your own; Table also builds the whole table from columns and rows.
TableRow
What it is: One row (<tr>). With href a 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