Content
Table
Rows of data in columns, with sortable headers and row selection, like a macOS table view.
"use client"
import { ScrollView } from "macbetter/scroll-view"
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
type TableSort,
} from "macbetter/table"
import { FileTextIcon, FolderIcon, ImageIcon, SheetIcon } from "lucide-react"
import { useMemo, useState } from "react"
const files = [
{ id: "q4", name: "Q4 Planning", kind: "Folder", modified: "2026-11-12T09:14", size: null, icon: FolderIcon },
{ id: "brand", name: "Brand Guidelines.pdf", kind: "PDF document", modified: "2026-11-10T16:40", size: 8_412_000, icon: FileTextIcon },
{ id: "budget", name: "Budget 2027.numbers", kind: "Numbers spreadsheet", modified: "2026-11-09T11:02", size: 212_000, icon: SheetIcon },
{ id: "hero", name: "Hero Shot.heic", kind: "HEIC image", modified: "2026-11-03T18:21", size: 3_204_000, icon: ImageIcon },
{ id: "notes", name: "Meeting Notes.md", kind: "Markdown text", modified: "2026-10-28T08:47", size: 4_100, icon: FileTextIcon },
{ id: "logo", name: "Logo.svg", kind: "SVG image", modified: "2026-10-21T13:30", size: 18_200, icon: ImageIcon },
{ id: "roadmap", name: "Roadmap.key", kind: "Keynote presentation", modified: "2026-10-14T10:05", size: 24_800_000, icon: FileTextIcon },
{ id: "invoices", name: "Invoices", kind: "Folder", modified: "2026-09-30T15:12", size: null, icon: FolderIcon },
{ id: "contract", name: "Contract — Signed.pdf", kind: "PDF document", modified: "2026-09-18T12:00", size: 640_000, icon: FileTextIcon },
]
const bytes = new Intl.NumberFormat("en", { notation: "compact", style: "unit", unit: "byte", unitDisplay: "narrow" })
const date = new Intl.DateTimeFormat("en", { dateStyle: "medium", timeStyle: "short" })
export default function Example() {
const [sort, setSort] = useState<TableSort>({ column: "modified", direction: "descending" })
const [selected, setSelected] = useState<string[]>(["brand"])
const rows = useMemo(() => {
const key = sort.column as "name" | "modified" | "size"
const sorted = [...files].sort((a, b) => {
const x = a[key] ?? -1
const y = b[key] ?? -1
return x < y ? -1 : x > y ? 1 : 0
})
return sort.direction === "ascending" ? sorted : sorted.reverse()
}, [sort])
return (
<ScrollView variant="bordered" orientation="both" style={{ width: "100%", maxWidth: 680, height: 220 }}>
<Table
aria-label="Documents"
selectionMode="multiple"
value={selected}
onValueChange={setSelected}
sort={sort}
onSortChange={setSort}
alternating
>
<TableHeader>
<TableColumn value="name">Name</TableColumn>
<TableColumn value="modified">Date Modified</TableColumn>
<TableColumn value="size" align="end">
Size
</TableColumn>
<TableColumn>Kind</TableColumn>
</TableHeader>
<TableBody>
{rows.map((file) => (
<TableRow key={file.id} value={file.id}>
<TableCell>
<file.icon aria-hidden="true" />
{file.name}
</TableCell>
<TableCell secondary>{date.format(new Date(file.modified))}</TableCell>
<TableCell secondary align="end">
{file.size === null ? "--" : bytes.format(file.size)}
</TableCell>
<TableCell secondary>{file.kind}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollView>
)
}When to use it
Use a table when people compare several attributes across many items: files by date and size, devices by software version. Each row is one item; each column is one attribute, and a column header can sort by it.
- Put the attribute people identify an item by in the first column.
- Right-align numbers, like sizes, so their digits line up.
- Use a List when each item has one or two attributes, and an Outline when items nest.
Usage
import {
Table,
TableBody,
TableCell,
TableColumn,
TableHeader,
TableRow,
} from "macbetter/table"Sorting
Give a column a value to make it sortable. Its header becomes a button; pressing it sorts by that column, and pressing it again reverses the order. The table reports { column, direction } through onSortChange and sets aria-sort on the header; you sort the rows. A chevron shows on the sorted column: up for ascending, down for descending.
Selection
selectionMode="single" or "multiple" lets people select rows, held in value (an array of row values) with onValueChange. A selected row fills with the accent while the table has focus, and turns gray when focus moves elsewhere, the way macOS shows an inactive selection. onAction runs on a double-click or Enter, like opening a file.
A plain table
Without selectionMode, the table is a native <table> with no keyboard behavior of its own, for data people only read.
| Device | Software | Last seen |
|---|---|---|
| MacBook Pro | macOS 26.1 | Now |
| iPhone 17 Pro | iOS 26.1 | 2 minutes ago |
| iPad Air | iPadOS 26.0 | Yesterday |
| Mac Studio | macOS 26.1 | Nov 9 |
import { Table, TableBody, TableCell, TableColumn, TableHeader, TableRow } from "macbetter/table"
const devices = [
{ id: "mbp", name: "MacBook Pro", os: "macOS 26.1", seen: "Now" },
{ id: "iphone", name: "iPhone 17 Pro", os: "iOS 26.1", seen: "2 minutes ago" },
{ id: "ipad", name: "iPad Air", os: "iPadOS 26.0", seen: "Yesterday" },
{ id: "studio", name: "Mac Studio", os: "macOS 26.1", seen: "Nov 9" },
]
export default function Example() {
return (
<div style={{ width: "100%", maxWidth: 560 }}>
<Table aria-label="Signed-in devices">
<TableHeader>
<TableColumn>Device</TableColumn>
<TableColumn>Software</TableColumn>
<TableColumn align="end">Last seen</TableColumn>
</TableHeader>
<TableBody>
{devices.map((device) => (
<TableRow key={device.id} value={device.id}>
<TableCell>{device.name}</TableCell>
<TableCell secondary>{device.os}</TableCell>
<TableCell secondary align="end">
{device.seen}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}Alternating rows and frames
alternating shades every other row, as Finder's list view does. To scroll a long table under a sticky header, put it in a Scroll View; variant="bordered" gives the view the hairline frame of a macOS table.
Platform behavior
- macOS. Rows are 24 points tall with 13-point text, the header is 28 points with 11-point labels and hairline dividers between columns, and the selection is a rounded fill inset from the table's edges. Adjacent selected rows join into one block.
- iOS and iPadOS. The table reads like a plain list: 44-point rows with 17-point text, hairline separators between rows, and no column dividers. On iPhone, keep it to two or three short columns, or use a List.
- In right-to-left layouts the columns run from the right, the first column's inset and the sort chevron mirror, and a Scroll View puts its scroll bar on the left.
Accessibility
- A table without selection keeps native table semantics. With selection it becomes a grid: each row is in one tab stop, and the row with focus takes the arrows.
- Up and Down Arrow move between rows and select, as in a macOS table. Shift with an arrow extends the selection. Command or Control with an arrow moves focus without selecting, and the focused row shows a ring. Space toggles the focused row, Command-A or Control-A selects every row (pressing it again keeps them selected), and Enter runs
onAction. - The focus ring follows the row's rounded selection shape. Inside a block of selected rows it's drawn in the selection's text color so it stays visible on the accent.
- Click selects a row; Shift-click extends from the last row chosen; Command-click or Control-click toggles one.
- Sortable headers are buttons inside the column header, which carries
aria-sort. - Name the table with
aria-label, or a caption.
API
Table
| Prop | Type | Default | Description |
|---|---|---|---|
selectionMode | "none" | "single" | "multiple" | "none" | How many rows can be selected. |
value / defaultValue | string[] | — | The selected rows' values. |
onValueChange | (value, details) => void | — | Runs when the selection wants to change. |
sort / defaultSort | { column: string; direction: "ascending" | "descending" } | null | — | The column the rows are sorted by. |
onSortChange | (sort, details) => void | — | Runs when a sortable header is pressed. |
onAction | (value) => void | — | Runs on a double-click or Enter on a row. |
alternating | boolean | false | Shades every other row. |
TableColumn
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | Identifies the column in `sort`. Set it to make the column sortable. |
align | "start" | "center" | "end" | "start" | Aligns the header, and should match its cells. |
TableRow and TableCell
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | — | TableRow: identifies the row in the selection. |
align | "start" | "center" | "end" | "start" | TableCell: aligns the cell's content. |
secondary | boolean | false | TableCell: draws the text in the secondary label color. |
TableHeader renders a thead with its row, and TableBody a tbody.
Data attributes and CSS variables
| Attribute or variable | On | Meaning |
|---|---|---|
data-selection-mode | .mb-table | none, single or multiple. |
data-alternating | .mb-table | Every other row is shaded. |
data-selected | .mb-table-row | The row is selected. |
data-sorted | .mb-table-column | ascending or descending on the sorted column. |
data-align | .mb-table-column, .mb-table-cell | The cell's alignment. |
--mb-table-row-height, --mb-table-header-height | platform | Row and header heights. |
--mb-table-inset, --mb-table-row-radius | platform | How far the selection sits from the edges, and its corners. |