# Table

> A React table component: semantic table parts plus a props API for small read-only tables that sort, total and turn into cards on phones.

- Type: React component (Tables & Lists)
- Access: Pro (the source needs a Wingo UI Pro plan, see https://wingo-ui.com/pricing)
- Version: 1.0.1
- Install: `npx wingo-ui@latest add table`
- Docs: https://wingo-ui.com/components/table

## Interaction

Click a sortable header to sort: text sorts A to Z first, numbers and dates biggest or newest first, a third click clears it, and the arrow flips on a spring. Clickable rows tint on hover and press; Tab reaches each row's first cell, where Enter or Space opens it, and the row shows a focus ring; Shift+F10 or the context menu key opens that row's actions menu. Row actions fade in on hover or focus and stay visible on touch. A table wider than its box scrolls sideways with a fade at the edge that still has content, and a pinned first column casts a hairline shadow once rows slide under it. On a phone every row becomes a card (title, amount at the top end, up to four label and value pairs, a Show more toggle for the rest), totals move into their own card, clickable cards dip when pressed, and a row of Sort by chips above the cards sorts them the way the headers do (tap to cycle, the arrow shows the direction). The stack layout lists receipt lines instead (pressable when rows are clickable) and the scroll layout keeps the table with its first column pinned.

## Installation

```bash
npx wingo-ui@latest add table
```

Pro item: run `npx wingo-ui@latest login` once with a Pro account. The CLI adds the files, their dependencies and a version record for updates.

With shadcn: `npx shadcn@latest add @wingo-ui/table` (needs the @wingo-ui registry with your API key in components.json)

Files:

- `components/ui/table.tsx`

npm dependencies: `npm i motion lucide-react`

Wingo UI dependencies (installed automatically):

- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [useControllableState](https://wingo-ui.com/components/use-controllable-state) ([Markdown](https://wingo-ui.com/components/use-controllable-state.md))
- [useLocale](https://wingo-ui.com/components/use-locale) ([Markdown](https://wingo-ui.com/components/use-locale.md))
- [useScrollEdges](https://wingo-ui.com/components/use-scroll-edges) ([Markdown](https://wingo-ui.com/components/use-scroll-edges.md))
- [Date utils](https://wingo-ui.com/components/date-utils) ([Markdown](https://wingo-ui.com/components/date-utils.md))
- [Motion presets](https://wingo-ui.com/components/motion) ([Markdown](https://wingo-ui.com/components/motion.md))
- [Number format](https://wingo-ui.com/components/number-format) ([Markdown](https://wingo-ui.com/components/number-format.md))
- [UIProvider](https://wingo-ui.com/components/ui-config) ([Markdown](https://wingo-ui.com/components/ui-config.md))

## Usage

```tsx
import { Table, type TableColumn } from "@/components/ui/table"

type Line = { id: string; product: string; qty: number; price: number; vat: number }

const lines: Line[] = [
  { id: "l1", product: "Northwind CRM Business license (yearly)", qty: 1, price: 1200, vat: 0.08 },
  { id: "l2", product: "Extra users", qty: 5, price: 120, vat: 0.08 },
  { id: "l3", product: "Setup and data migration", qty: 12, price: 95, vat: 0.08 },
]

const columns: TableColumn<Line>[] = [
  { key: "product", header: "Product / service", sortable: true, footer: ["Subtotal", "Sales tax 8%", "Total"] },
  { key: "qty", header: "Qty", type: "number" },
  { key: "price", header: "Unit price", type: "currency" },
  { key: "vat", header: "Tax", type: "percent" },
  {
    key: "total",
    header: "Total",
    type: "currency",
    sortable: true,
    accessor: (line) => line.qty * line.price,
    footer: ["$2,940.00", "$235.20", "$3,175.20"],
  },
]

export function InvoiceLines() {
  return <Table caption="Invoice #2026-0412" columns={columns} data={lines} locale="en-US" />
}

// parts, for tables the props API does not cover (same file, same look):
// TableRoot { density, variant, striped, stickyHeader, stickyFirstColumn } holds the context;
// TableScroll { maxHeight, fades, pinnedStart } scrolls with edge fades;
// TableHead { align, sort, onSort, pinned, width, minWidth, sortLabel, firstDirection } renders TableSortButton + aria-sort;
// TableRow { selected, clickable }, TableCell { align, pinned, truncate, numeric, wrap },
// TableEmpty { colSpan }, TableSkeleton { rows, columns, align };
// phone cards: TableCards { variant } > TableCard { interactive, selected } > TableCardHeader
// (TableCardTitle { onClick }, TableCardSubtitle, TableCardMeta, TableCardActions) + TableCardFields > TableCardField { label, wide };
// helpers: formatCellValue(value, type, { locale, currency }), compareValues(a, b, type, locale),
// TABLE_DENSITY, MOBILE_CARD, MOBILE_CARD_FIELDS (shared with data-table)
import {
  TableRoot, TableScroll, TableElement, TableCaption, TableHeader, TableBody,
  TableRow, TableHead, TableCell, formatCellValue,
} from "@/components/ui/table"

export function PriceList({ items }: { items: { id: string; name: string; price: number }[] }) {
  return (
    <TableRoot density="compact" variant="lined">
      <TableScroll>
        <TableElement>
          <TableCaption hidden>Price list</TableCaption>
          <TableHeader>
            <TableRow>
              <TableHead>Service</TableHead>
              <TableHead align="end">Price</TableHead>
            </TableRow>
          </TableHeader>
          <TableBody>
            {items.map((item) => (
              <TableRow key={item.id}>
                <TableCell>{item.name}</TableCell>
                <TableCell align="end" numeric>{formatCellValue(item.price, "currency", { locale: "en-US" })}</TableCell>
              </TableRow>
            ))}
          </TableBody>
        </TableElement>
      </TableScroll>
    </TableRoot>
  )
}
```

## Props

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `caption?` | `ReactNode` | `""` | The table's name. It stays for screen readers even when hidden, and names the card list on phones. Always pass one. The playground falls back to the invoice number. |
| `hideCaption?` | `boolean` | `true` | Keeps the caption for screen readers only. Turn it off to show it under the table. |
| `emptyTitle?` | `string` | `"No data"` | Title of the default empty state (the playground's Clear button empties the invoice). |
| `variant?` | `"plain" \| "surface" \| "lined"` | `"surface"` | surface is a raised panel with a hairline under the header; plain has no container and a tinted header band; lined keeps only the horizontal hairlines, for tables inside a card. |
| `density?` | `"compact" \| "default" \| "comfortable"` | `"default"` | Row height 36 / 48 / 56px with matching padding and type. |
| `striped?` | `boolean` | `false` | Tints every second row with a 2.5% foreground wash. |
| `loading?` | `boolean` | `false` | Skeleton rows (and skeleton cards on phones) in the real column layout, with aria-busy. |
| `skeletonCount?` | `number` | `5` | How many skeleton rows or cards show while loading. |
| `showFooter?` | `boolean` | `true` | The footer row with each column's footer (totals). Automatic when any column has a footer; false hides it. On phones the totals become their own card. |
| `stickyHeader?` | `boolean` | `false` | The header sticks while rows scroll: to the page (offset by --table-sticky-top, e.g. var(--topbar-offset)), or to the table's own box with maxHeight. |
| `stickyFirstColumn?` | `boolean` | `false` | The first column stays put while scrolling sideways and casts a hairline shadow once rows slide under it. |
| `maxHeight?` | `number \| string` | `0` | Caps the table's height (px or any CSS length); the rows scroll inside with a fade at the bottom. 0 means no cap. |
| `mobileLayout?` | `"cards" \| "scroll" \| "stack"` | `"cards"` | Below 768px: cards (each row a card), scroll (the table scrolls sideways with its first column pinned) or stack (label and value lines without card chrome, for receipts). Both layouts are in the server HTML, so nothing jumps. |
| `mobileSort?` | `boolean` | `true` | Below 768px, a row of Sort by chips above the cards or stack, one per sortable column, since the headers are not there to press. Hidden when nothing is sortable. |
| `cardVariant?` | `"separate" \| "grouped"` | `"separate"` | Phone cards as separate raised cards, or one iOS-style grouped panel with hairlines. |
| `locale?` | `string` | `""` | Intl locale for numbers, money and dates ("$12,480.00", "9/18/2026" in en-US). Empty uses the UIProvider's, then the browser's. The playground falls back to en-US. |
| `currency?` | `string` | `"USD"` | ISO 4217 code for currency columns that do not set their own. |
| `columns` | `TableColumn<T>[]` |  | { key, header, accessor?, cell?, type?: "text" \| "number" \| "currency" \| "percent" \| "date" \| "datetime" \| "boolean", currency?, align?, width?, minWidth?, sortable?, sortValue?, truncate?, footer?: ReactNode \| ReactNode[], mobile?: "title" \| "subtitle" \| "meta" \| "field" \| "hidden", className?, headerClassName? }. Numbers end-align with tabular figures; cell always wins over the type. On phones the first column is the card title and the last numeric one its top-end value unless mobile says otherwise. |
| `data` | `T[]` |  | The rows. A column without accessor reads row[key]; empty values show a muted dash. |
| `getRowKey?` | `(row: T, index: number) => string` | `"row.id ?? index"` | A stable key per row. Duplicate keys log a warning in development. |
| `sort?` | `{ key: string; direction: "asc" \| "desc" } \| null` |  | Controlled sort. When set (null included), the table only draws the arrows and leaves sorting the data to you. |
| `defaultSort?` | `{ key: string; direction: "asc" \| "desc" } \| null` | `"null"` | Starting sort while uncontrolled; the table sorts on the client with a locale-aware collator. |
| `onSortChange?` | `(sort: TableSort \| null) => void` |  | Fires on every header press, controlled or not. |
| `onRowClick?` | `(row: T) => void` |  | Makes rows clickable. The first cell renders a real button for the keyboard; clicks on controls inside the row (menus, links) never open it. |
| `isRowSelected?` | `(row: T) => boolean` |  | Paints rows (and cards) as selected. Behavior stays with you; data-table owns selection. |
| `rowActions?` | `(row: T) => ReactNode` |  | A trailing cell on desktop (revealed on hover or focus, always on touch) and the card's top-end slot on phones, typically a DropdownMenu with a "⋯" trigger. |
| `renderCard?` | `(row: T, index: number) => ReactNode` |  | Your own phone card, built from the TableCard parts. |
| `emptyState?` | `ReactNode` | `"<EmptyState size=\"sm\" />"` | Replaces the default empty state, spanning every column. |
| `labels?` | `Partial<TableLabels>` |  | Strings: sortAscending / sortDescending / clearSort ("Sort by {column}, ascending"), empty, actions, showMore, showLess, yes, no, totals, sortBy. DEFAULT_TABLE_LABELS holds the English defaults. |
| `classNames?` | `Partial<Record<"root" \| "scroll" \| "table" \| "caption" \| "header" \| "head" \| "body" \| "row" \| "cell" \| "footer" \| "empty" \| "cards" \| "card" \| "cardTitle" \| "cardFields" \| "stack", string>>` |  | Extra classes for the inner parts. |
| `className?` | `string` |  | Extra classes for the outer wrapper that holds the table and its phone layout. |

## Parts

### TableRoot (component)

The outer part of a hand-built table: holds density, variant, stripes and sticky settings for every part inside.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `density?` | `"compact" \| "default" \| "comfortable"` | `"default"` | Row height 36 / 48 / 56px with matching cell padding. |
| `variant?` | `"plain" \| "surface" \| "lined"` | `"surface"` | A raised surface, a plain table, or hairlines only. |
| `striped?` | `boolean` | `false` | Tints every other body row. |
| `stickyHeader?` | `boolean` | `false` | Keeps the header row at the top of TableScroll. |
| `stickyFirstColumn?` | `boolean` | `false` | Keeps the first column in view while scrolling sideways. |

### TableScroll (component)

The scroll box around the table element, with fades toward any edge that has more to scroll.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `maxHeight?` | `number \| string` |  | Caps the height; the body scrolls inside and a sticky header sticks to this box. |
| `fades?` | `boolean` | `true` | Fades the content out toward an edge with more to scroll. |
| `pinnedStart?` | `boolean` |  | Keeps the first column out of the left fade. |

### TableElement (component)

The native table element: full width, separated borders without spacing, caption at the bottom.

### TableCaption (component)

The table's caption. hidden keeps it for screen readers only.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `hidden?` | `boolean` | `false` | Visually hidden, still names the table. |

### TableHeader (component)

The thead section; rows inside it take the header look.

### TableBody (component)

The tbody section; rows inside it get the density height, hairlines, stripes and the hover tint when clickable.

### TableFooter (component)

The tfoot section for totals, with a top rule and medium weight.

### TableRow (component)

A row. selected tints it and sets aria-selected; an onClick (or clickable) gives it the pressable look.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `selected?` | `boolean` | `false` | Selected tint and aria-selected. |
| `clickable?` | `boolean` |  | Forces the interactive look without an onClick. |

### TableHead (component)

A header cell. With onSort it renders a sort button and sets aria-sort.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align?` | `"start" \| "center" \| "end"` | `"start"` | Text alignment; end for numbers. |
| `sort?` | `"asc" \| "desc" \| false` | `false` | The current sort of this column. |
| `onSort?` | `() => void` |  | Makes the header a sort button. |
| `pinned?` | `boolean \| "phone"` | `false` | Sticks to the start while scrolling sideways; phone pins below 768px only. |
| `width?` | `number \| string` |  | Column width. |
| `minWidth?` | `number \| string` |  | Column minimum width. |
| `sortLabel?` | `string` |  | The sort button's aria-label; defaults to the header text. |
| `firstDirection?` | `"asc" \| "desc"` |  | The direction the first press sorts in. |

### TableSortButton (component)

The sort button TableHead renders, with the direction arrow and an aria-label built from the labels; use it alone in a custom header.

### TableCell (component)

A body cell with alignment, pinning, truncation and tabular numbers.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `align?` | `"start" \| "center" \| "end"` | `"start"` | Text alignment. |
| `pinned?` | `boolean \| "phone"` | `false` | Sticks to the start while scrolling sideways. |
| `truncate?` | `boolean` | `false` | One line with an ellipsis; string content also goes into a title. |
| `numeric?` | `boolean` | `false` | Tabular numbers. |
| `wrap?` | `boolean` | `false` | Lets the text wrap instead of scrolling sideways. |

### TableEmpty (component)

A full-width row for an empty state; colSpan is the column count.

### TableSkeleton (component)

Placeholder rows with shimmering bars while data loads.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `rows?` | `number` | `5` | How many rows. |
| `columns?` | `number \| (number \| string)[]` | `4` | A count, or one width hint per column. |
| `align?` | `("start" \| "center" \| "end")[]` |  | Aligns each column's bar. |

### TableCards (component)

The phone list that replaces the table below 768px: separate raised cards, or one grouped panel with hairlines (variant).

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `variant?` | `"separate" \| "grouped"` | `"separate"` | Raised cards with gaps, or one iOS-style grouped panel. |

### TableCard (component)

One phone card. interactive adds the press feedback and focus ring of its title button; selected tints it.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `interactive?` | `boolean` | `false` | Pressable look. |
| `selected?` | `boolean` | `false` | Selected tint. |

### TableCardHeader (component)

The top row of a card: title block on the left, meta or actions on the right.

### TableCardTitle (component)

The card title, clamped to two lines. With onClick it becomes a button whose hit area covers the whole card.

### TableCardSubtitle (component)

The muted line under the card title.

### TableCardMeta (component)

The right side of the card header, for an amount in tabular numbers or a status badge.

### TableCardActions (component)

Buttons inside a card that stay clickable above the card's stretched title button.

### TableCardFields (component)

A two-column definition list of the remaining fields.

### TableCardField (component)

One label and value in TableCardFields; wide spans both columns.

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `label` | `ReactNode` |  | The field name. |
| `wide?` | `boolean` | `false` | Spans both columns. |

### formatCellValue (function)

A cell value as text in the locale, the way the props API prints it: "$12,480.00", "19%" (from 0.19), "9/18/2026". Returns "" for empty values. Signature: (value: unknown, type?: TableColumnType, options?: { locale?: string; currency?: string }) => string.

### compareValues (function)

The sort comparator of the props API: empty values last, numbers by value, dates by time, text by Intl.Collator. Signature: (a: unknown, b: unknown, type?: TableColumnType, locale?: string) => number.

### DEFAULT_TABLE_LABELS (constant)

The English strings (sort announcements, empty, actions, show more / less, yes / no, totals, sort by). Spread it to translate a few. Type: TableLabels.

### TABLE_DENSITY (constant)

The row, cell and header classes per density, shared with data-table so both tables line up.

### MOBILE_CARD (constant)

The class string of a phone card (rounded surface, padding, raised shadow), shared with data-table and file-list.

### MOBILE_CARD_FIELDS (constant)

The class string of the two-column field grid inside a phone card.

### TableColumn (type)

One column of the props API: key, header, accessor or cell, type, currency, align, width, sortable, sortValue, truncate, wrap, footer and its role on a phone card (mobile).

## Examples

- **Clients with row actions**: The 12 canonical clients: clickable rows, status badges, a ⋯ menu per row, sorted by balance; cards on a phone.
- **Price list inside a card**: variant="lined" keeps only hairlines so the table sits in a Card; a receipt-style stack on phones.
- **Compact with a sticky header**: 40 orders, density="compact", maxHeight 320 and stickyHeader; the rows scroll under the header.
- **Pinned first column**: Ten months of revenue per city with stickyFirstColumn, striped rows, totals and mobileLayout="scroll".
- **Loading and empty**: Skeleton rows in the real column layout while loading, and a custom empty state spanning every column.
- **Receipt stack on phones**: mobileLayout="stack": label and value lines with the totals under a dashed rule, like a receipt.
- **Parts API: plan comparison**: Hand-built from TableRoot, TableHead and TableCell: check marks, a highlighted column, the first column pinned on phones.

## See also

- [Data Table](https://wingo-ui.com/components/data-table) ([Markdown](https://wingo-ui.com/components/data-table.md))
- [Description List](https://wingo-ui.com/components/description-list) ([Markdown](https://wingo-ui.com/components/description-list.md))
- [Empty State](https://wingo-ui.com/components/empty-state) ([Markdown](https://wingo-ui.com/components/empty-state.md))
- [Skeleton](https://wingo-ui.com/components/skeleton) ([Markdown](https://wingo-ui.com/components/skeleton.md))
- [Pagination](https://wingo-ui.com/components/pagination) ([Markdown](https://wingo-ui.com/components/pagination.md))
- [Card](https://wingo-ui.com/components/card) ([Markdown](https://wingo-ui.com/components/card.md))

---

Canonical: https://wingo-ui.com/components/table
More components: https://wingo-ui.com/components · Index for agents: https://wingo-ui.com/llms.txt
