# Table

A table for records that people compare by column, such as members, invoices or orders.

```tsx
import { StatusBadge, Table, TableBody, TableCell, TableHead, TableHeader, TableRow, type StatusTone } from 'ferry-ui'

type InvoiceStatus = 'paid' | 'open' | 'overdue'

const STATUS: Record<InvoiceStatus, { tone: StatusTone; label: string }> = {
  paid: { tone: 'success', label: 'Paid' },
  open: { tone: 'info', label: 'Open' },
  overdue: { tone: 'destructive', label: 'Overdue' },
}

const INVOICES: { number: string; customer: string; status: InvoiceStatus; amount: string }[] = [
  { number: 'INV-2041', customer: 'Northwind Traders', status: 'paid', amount: '$1,250.00' },
  { number: 'INV-2042', customer: 'Acme', status: 'open', amount: '$3,480.00' },
  { number: 'INV-2043', customer: 'Globex', status: 'overdue', amount: '$920.50' },
  { number: 'INV-2044', customer: 'Initech', status: 'paid', amount: '$415.00' },
]

export default function TableHero() {
  return (
    <Table aria-label="Invoices">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Invoice</TableHead>
          <TableHead>Customer</TableHead>
          <TableHead>Status</TableHead>
          <TableHead className="text-right">Amount</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {INVOICES.map((invoice) => (
          <TableRow key={invoice.number}>
            <TableCell className="font-mono text-[13px]">{invoice.number}</TableCell>
            <TableCell>{invoice.customer}</TableCell>
            <TableCell>
              <StatusBadge {...STATUS[invoice.status]} />
            </TableCell>
            <TableCell className="text-right tabular">{invoice.amount}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

## Usage guidelines

- **Records with the same columns.** Use a table for records that people compare by column.
- **One record is not a table.** For the facts about one record, use [Description List](/docs/components/description-list).
- **Named things can be cards.** For a collection that people browse, use [Resource Card](/docs/components/resource-card).
- **Keep the header in each state.** For the loading, empty and error rows, use [Table States](/docs/components/table-states).
- **Show a state with a status.** In a cell, use the `StatusBadge` of [Status](/docs/components/status), not a [Badge](/docs/components/badge).

## Anatomy

Import the parts and put them together.

```tsx title="Anatomy"

<Table>
  <TableCaption />
  <TableHeader>
    <TableRow>
      <TableHead />
    </TableRow>
  </TableHeader>
  <TableBody>
    <TableRow>
      <TableCell />
    </TableRow>
  </TableBody>
  <TableFooter>
    <TableRow>
      <TableCell />
    </TableRow>
  </TableFooter>
</Table>
```

| Part | Element | Role |
| --- | --- | --- |
| `Table` | `table` | The table, in a container with a border. |
| `TableHeader` | `thead` | The section for the column labels. |
| `TableBody` | `tbody` | The section for the data rows. |
| `TableFooter` | `tfoot` | The section for the totals. |
| `TableRow` | `tr` | One row. It changes color on hover. |
| `TableHead` | `th` | The label of one column. |
| `TableCell` | `td` | One value. |
| `TableCaption` | `caption` | A short note below the rows. |

Give `className="hover:bg-transparent"` to the row of the header. The header then does not change on hover.

## Examples

### Footer and caption

Use `TableFooter` for totals only. `TableCaption` shows a note below the rows. It also gives the table its accessible name.

```tsx
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from 'ferry-ui'

const LINES = [
  { product: 'Standard plan', quantity: 12, total: '$1,440.00' },
  { product: 'Extra seats', quantity: 3, total: '$90.00' },
  { product: 'Priority support', quantity: 1, total: '$250.00' },
]

export default function TableFooterCaption() {
  return (
    <Table>
      <TableCaption>Order ORD-10482. Amounts in USD.</TableCaption>
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Product</TableHead>
          <TableHead className="text-right">Qty</TableHead>
          <TableHead className="text-right">Total</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {LINES.map((line) => (
          <TableRow key={line.product}>
            <TableCell>{line.product}</TableCell>
            <TableCell className="text-right tabular">{line.quantity}</TableCell>
            <TableCell className="text-right tabular">{line.total}</TableCell>
          </TableRow>
        ))}
      </TableBody>
      <TableFooter>
        <TableRow className="hover:bg-transparent">
          <TableCell colSpan={2}>Total</TableCell>
          <TableCell className="text-right tabular">$1,780.00</TableCell>
        </TableRow>
      </TableFooter>
    </Table>
  )
}
```

### Numbers and long values

A cell does not wrap its text. Align the numbers with `text-right tabular`. For a long value, give the cell `w-full max-w-0`. Put a `truncate` element in the cell.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ferry-ui'

const PROJECTS = [
  {
    name: 'Atlas',
    description: 'New first steps for customers, with a guided setup, sample data and a progress bar',
    tasks: 128,
  },
  { name: 'Beacon', description: 'Invoices that follow the usage of each customer', tasks: 42 },
  { name: 'Compass', description: 'Dashboards for the sales team and the support team', tasks: 7 },
]

export default function TableColumns() {
  return (
    <Table aria-label="Projects">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Project</TableHead>
          <TableHead>Description</TableHead>
          <TableHead className="text-right">Open tasks</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {PROJECTS.map((project) => (
          <TableRow key={project.name}>
            <TableCell className="font-medium">{project.name}</TableCell>
            {/* This column takes the free width. Its text does not push the table wider. */}
            <TableCell className="w-full max-w-0 text-foreground-light">
              <span className="block truncate" title={project.description}>
                {project.description}
              </span>
            </TableCell>
            <TableCell className="text-right tabular">{project.tasks}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

### Row actions

Put a [Dropdown Menu](/docs/components/dropdown-menu) on a `ghost` icon button in the last cell. The row keeps its hover color while the menu is open.

```tsx
import {
  Button,
  DropdownMenu,
  DropdownMenuContent,
  DropdownMenuItem,
  DropdownMenuTrigger,
  Table,
  TableBody,
  TableCell,
  TableHead,
  TableHeader,
  TableRow,
  toast,
} from 'ferry-ui'
import { MoreHorizontal } from 'lucide-react'

const MEMBERS = [
  { name: 'Maya Chen', role: 'Owner' },
  { name: 'Jonas Weber', role: 'Admin' },
  { name: 'Priya Patel', role: 'Member' },
]

export default function TableRowActions() {
  return (
    <Table aria-label="Members">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead>Name</TableHead>
          <TableHead>Role</TableHead>
          <TableHead className="w-[1%]">
            <span className="sr-only">Actions</span>
          </TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {MEMBERS.map(({ name, role }) => (
          <TableRow key={name}>
            <TableCell className="font-medium">{name}</TableCell>
            <TableCell>{role}</TableCell>
            <TableCell>
              <DropdownMenu>
                <DropdownMenuTrigger asChild>
                  <Button variant="ghost" size="icon-tiny" icon={<MoreHorizontal />} aria-label={`Actions for ${name}`} />
                </DropdownMenuTrigger>
                <DropdownMenuContent align="end" className="w-44">
                  <DropdownMenuItem onSelect={() => toast('Role changed')}>Change role</DropdownMenuItem>
                  <DropdownMenuItem onSelect={() => toast('Invitation sent')}>Send an invitation</DropdownMenuItem>
                </DropdownMenuContent>
              </DropdownMenu>
            </TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

### Selected rows

Add a column of [Checkbox](/docs/components/checkbox) controls. Set `data-state="selected"` on a row to give it the selection color.

```tsx
import * as React from 'react'
import { Checkbox, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ferry-ui'

const API_KEYS = [
  { id: 'k1', name: 'Production backend', scope: 'Read and write', lastUsed: '2 minutes ago' },
  { id: 'k2', name: 'Analytics export', scope: 'Read only', lastUsed: '3 hours ago' },
  { id: 'k3', name: 'Staging', scope: 'Read and write', lastUsed: '2 days ago' },
]

export default function TableSelection() {
  const [selected, setSelected] = React.useState<string[]>(['k2'])

  function toggle(id: string, checked: boolean) {
    setSelected((current) => (checked ? [...current, id] : current.filter((key) => key !== id)))
  }

  return (
    <Table aria-label="API keys">
      <TableHeader>
        <TableRow className="hover:bg-transparent">
          <TableHead className="w-10">
            <Checkbox
              aria-label="Select all keys"
              checked={selected.length === API_KEYS.length}
              onCheckedChange={(checked) => setSelected(checked === true ? API_KEYS.map((key) => key.id) : [])}
            />
          </TableHead>
          <TableHead>Name</TableHead>
          <TableHead>Scope</TableHead>
          <TableHead>Last used</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {API_KEYS.map((key) => {
          const isSelected = selected.includes(key.id)
          return (
            <TableRow key={key.id} data-state={isSelected ? 'selected' : undefined}>
              <TableCell>
                <Checkbox
                  aria-label={`Select ${key.name}`}
                  checked={isSelected}
                  onCheckedChange={(checked) => toggle(key.id, checked === true)}
                />
              </TableCell>
              <TableCell className="font-medium">{key.name}</TableCell>
              <TableCell>{key.scope}</TableCell>
              <TableCell className="text-foreground-light">{key.lastUsed}</TableCell>
            </TableRow>
          )
        })}
      </TableBody>
    </Table>
  )
}
```

### Clickable rows

Spread `rowLinkProps()` on a row to open the record on a click. A click on a link, a button or a menu in the row does not open the record.

```tsx
import * as React from 'react'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, rowLinkProps } from 'ferry-ui'

const PROJECTS = [
  { id: 'atlas', name: 'Atlas', owner: 'Maya Chen', updated: '5 minutes ago' },
  { id: 'beacon', name: 'Beacon', owner: 'Jonas Weber', updated: 'Yesterday' },
  { id: 'compass', name: 'Compass', owner: 'Priya Patel', updated: 'Aug 2, 2026' },
]

const LINK =
  'rounded-sm font-medium underline-offset-4 outline-none hover:underline focus-visible:ring-2 focus-visible:ring-ring'

export default function TableClickableRows() {
  // In an app, the row and the link go to the page of the project.
  const [opened, setOpened] = React.useState<string>()

  return (
    <div className="flex flex-col gap-3">
      <Table aria-label="Projects">
        <TableHeader>
          <TableRow className="hover:bg-transparent">
            <TableHead>Project</TableHead>
            <TableHead>Owner</TableHead>
            <TableHead>Updated</TableHead>
          </TableRow>
        </TableHeader>
        <TableBody>
          {PROJECTS.map((project) => (
            <TableRow key={project.id} {...rowLinkProps(() => setOpened(project.name))}>
              <TableCell>
                <a
                  href={`#${project.id}`}
                  onClick={(event) => {
                    event.preventDefault()
                    setOpened(project.name)
                  }}
                  className={LINK}
                >
                  {project.name}
                </a>
              </TableCell>
              <TableCell className="text-foreground-light">{project.owner}</TableCell>
              <TableCell className="text-foreground-light">{project.updated}</TableCell>
            </TableRow>
          ))}
        </TableBody>
      </Table>
      <p className="text-[13px] text-foreground-light" aria-live="polite">
        {opened ? `Opened: ${opened}` : 'Click a row to open a project.'}
      </p>
    </div>
  )
}
```

### Sticky header

Give the container a maximum height with `containerClassName`. The container then scrolls. Add `sticky top-0 z-10` and an opaque background to `TableHeader`. The labels then stay in view.

```tsx
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'ferry-ui'

const ACTORS = ['Maya Chen', 'Jonas Weber', 'Priya Patel', 'System']
const ACTIONS = ['member.invited', 'invoice.paid', 'api_key.created', 'project.renamed', 'role.updated']
const TARGETS = ['Atlas', 'INV-2043', 'Production backend', 'Beacon', 'Compass']

const EVENTS = Array.from({ length: 24 }, (_, index) => ({
  id: `evt_${1000 + index}`,
  time: `16:${String(59 - index).padStart(2, '0')}`,
  actor: ACTORS[index % ACTORS.length],
  action: ACTIONS[index % ACTIONS.length],
  target: TARGETS[index % TARGETS.length],
}))

export default function TableStickyHeader() {
  return (
    <Table
      aria-label="Audit log"
      // The container has a maximum height: it scrolls, and the keyboard can reach it.
      containerClassName="max-h-80"
      containerProps={{ tabIndex: 0, role: 'region', 'aria-label': 'Audit log, scrollable' }}
    >
      {/* The inset shadow draws the line below the header: a border does not move with a sticky header. */}
      <TableHeader className="sticky top-0 z-10 bg-surface-100 [&_th]:shadow-[inset_0_-1px_0_var(--border)]">
        <TableRow className="bg-surface-200 hover:bg-surface-200">
          <TableHead>Time</TableHead>
          <TableHead>Actor</TableHead>
          <TableHead>Event</TableHead>
          <TableHead>Target</TableHead>
        </TableRow>
      </TableHeader>
      <TableBody>
        {EVENTS.map((event) => (
          <TableRow key={event.id}>
            <TableCell className="font-mono text-[13px] text-foreground-light tabular">{event.time}</TableCell>
            <TableCell>{event.actor}</TableCell>
            <TableCell className="font-mono text-[13px]">{event.action}</TableCell>
            <TableCell className="text-foreground-light">{event.target}</TableCell>
          </TableRow>
        ))}
      </TableBody>
    </Table>
  )
}
```

### Table in a card

In a [Card](/docs/components/card), remove the frame of the table. The border of the card is then the only border.

```tsx
<Card>
  <CardHeader>
    <CardTitle>Recent invoices</CardTitle>
  </CardHeader>
  <Table aria-label="Recent invoices" containerClassName="rounded-none border-0 shadow-none">
    {/* the header and the rows */}
  </Table>
</Card>
```

## Accessibility

- Name each table with `aria-label` or with a `TableCaption`.
- A column that has only icons needs an `sr-only` label in its header cell.
- Keep a real link in the first cell of a clickable row. Keyboard users and screen readers use this link.
- A table that scrolls and has no link or button needs keyboard access. Pass `tabIndex`, `role` and `aria-label` in `containerProps`.

## API reference

`Table` also accepts each attribute of the `<table>` element. The other parts accept the attributes of their element.

### Table

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `containerClassName` | `string` |  | Classes for the bordered scroll container around the `<table>`. Use it to cap the height (`max-h-80` for a scrolling body / sticky header), to drop the shadow (`shadow-none`) inside a padded card section, or to drop the whole frame (`rounded-none border-0 shadow-none`) when the table sits edge to edge in a card. |
| `containerProps` | `Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "children">` |  | Extra attributes for the scroll container (`<div>`), e.g. `tabIndex`, `role` and `aria-label` so keyboard users can focus and scroll a table that has no focusable content, or a `ref` to read its scroll position. Its `className` is merged before `containerClassName`. |

### TableHeader

`TableHeader` has no props of its own. It accepts the attributes of the element it renders.

### TableBody

`TableBody` has no props of its own. It accepts the attributes of the element it renders.

### TableFooter

`TableFooter` has no props of its own. It accepts the attributes of the element it renders.

### TableRow

`TableRow` has no props of its own. It accepts the attributes of the element it renders.

### TableHead

`TableHead` has no props of its own. It accepts the attributes of the element it renders.

### TableCell

`TableCell` has no props of its own. It accepts the attributes of the element it renders.

### TableCaption

`TableCaption` has no props of its own. It accepts the attributes of the element it renders.

### rowLinkProps

`rowLinkProps(onOpen)` returns the props that make a row clickable. Spread them on a `TableRow`.

```ts
function rowLinkProps(onOpen: (event: React.MouseEvent<HTMLTableRowElement>) => void): {
  className: string
  onClick: React.MouseEventHandler<HTMLTableRowElement>
}
```

| Name | Type | Role |
| --- | --- | --- |
| `onOpen` | `(event) => void` | The function that opens the record. It gets the click event. |
| `className` | `string` | The class for the pointer cursor. |
| `onClick` | `MouseEventHandler` | The click handler of the row. |
