Primitives
Table
A table for records that people compare by column, such as members, invoices or orders.
Primitives
A table for records that people compare by column, such as members, invoices or orders.
| Invoice | Customer | Status | Amount |
|---|---|---|---|
| INV-2041 | Northwind Traders | Paid | $1,250.00 |
| INV-2042 | Acme | Open | $3,480.00 |
| INV-2043 | Globex | Overdue | $920.50 |
| INV-2044 | Initech | Paid | $415.00 |
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>
)
}StatusBadge of Status, not a Badge.Import the parts and put them together.
import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from 'ferry-ui'
<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.
Use TableFooter for totals only. TableCaption shows a note below the rows. It also gives the table its accessible name.
| Product | Qty | Total |
|---|---|---|
| Standard plan | 12 | $1,440.00 |
| Extra seats | 3 | $90.00 |
| Priority support | 1 | $250.00 |
| Total | $1,780.00 | |
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>
)
}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.
| Project | Description | Open tasks |
|---|---|---|
| Atlas | New first steps for customers, with a guided setup, sample data and a progress bar | 128 |
| Beacon | Invoices that follow the usage of each customer | 42 |
| Compass | Dashboards for the sales team and the support team | 7 |
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>
)
}Put a Dropdown Menu on a ghost icon button in the last cell. The row keeps its hover color while the menu is open.
| Name | Role | Actions |
|---|---|---|
| Maya Chen | Owner | |
| Jonas Weber | Admin | |
| Priya Patel | Member |
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>
)
}Add a column of Checkbox controls. Set data-state="selected" on a row to give it the selection color.
| Name | Scope | Last used | |
|---|---|---|---|
| Production backend | Read and write | 2 minutes ago | |
| Analytics export | Read only | 3 hours ago | |
| Staging | Read and write | 2 days ago |
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>
)
}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.
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>
)
}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.
| Time | Actor | Event | Target |
|---|---|---|---|
| 16:59 | Maya Chen | member.invited | Atlas |
| 16:58 | Jonas Weber | invoice.paid | INV-2043 |
| 16:57 | Priya Patel | api_key.created | Production backend |
| 16:56 | System | project.renamed | Beacon |
| 16:55 | Maya Chen | role.updated | Compass |
| 16:54 | Jonas Weber | member.invited | Atlas |
| 16:53 | Priya Patel | invoice.paid | INV-2043 |
| 16:52 | System | api_key.created | Production backend |
| 16:51 | Maya Chen | project.renamed | Beacon |
| 16:50 | Jonas Weber | role.updated | Compass |
| 16:49 | Priya Patel | member.invited | Atlas |
| 16:48 | System | invoice.paid | INV-2043 |
| 16:47 | Maya Chen | api_key.created | Production backend |
| 16:46 | Jonas Weber | project.renamed | Beacon |
| 16:45 | Priya Patel | role.updated | Compass |
| 16:44 | System | member.invited | Atlas |
| 16:43 | Maya Chen | invoice.paid | INV-2043 |
| 16:42 | Jonas Weber | api_key.created | Production backend |
| 16:41 | Priya Patel | project.renamed | Beacon |
| 16:40 | System | role.updated | Compass |
| 16:39 | Maya Chen | member.invited | Atlas |
| 16:38 | Jonas Weber | invoice.paid | INV-2043 |
| 16:37 | Priya Patel | api_key.created | Production backend |
| 16:36 | System | project.renamed | Beacon |
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>
)
}In a Card, remove the frame of the table. The border of the card is then the only border.
<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>aria-label or with a TableCaption.sr-only label in its header cell.tabIndex, role and aria-label in containerProps.Table also accepts each attribute of the <table> element. The other parts accept the attributes of their element.
| Prop | Type | Default |
|---|---|---|
containerClassName | string | - |
Classes for the bordered scroll container around the | ||
containerProps | Omit<DetailedHTMLProps<HTMLAttributes<HTMLDivElement>, HTMLDivElement>, "children"> | - |
Extra attributes for the scroll container ( | ||
TableHeader has no props of its own. It accepts the attributes of the element it renders.
TableBody has no props of its own. It accepts the attributes of the element it renders.
TableFooter has no props of its own. It accepts the attributes of the element it renders.
TableRow has no props of its own. It accepts the attributes of the element it renders.
TableHead has no props of its own. It accepts the attributes of the element it renders.
TableCell has no props of its own. It accepts the attributes of the element it renders.
TableCaption has no props of its own. It accepts the attributes of the element it renders.
rowLinkProps(onOpen) returns the props that make a row clickable. Spread them on a TableRow.
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. |