macbetter

Content

Table

Rows of data in columns, with sortable headers and row selection, like a macOS table view.

Kind
Q4 PlanningNov 12, 2026, 9:14 AM--Folder
Brand Guidelines.pdfNov 10, 2026, 4:40 PM8.4MBPDF document
Budget 2027.numbersNov 9, 2026, 11:02 AM212KBNumbers spreadsheet
Hero Shot.heicNov 3, 2026, 6:21 PM3.2MBHEIC image
Meeting Notes.mdOct 28, 2026, 8:47 AM4.1KBMarkdown text
Logo.svgOct 21, 2026, 1:30 PM18KBSVG image
Roadmap.keyOct 14, 2026, 10:05 AM25MBKeynote presentation
InvoicesSep 30, 2026, 3:12 PM--Folder
Contract — Signed.pdfSep 18, 2026, 12:00 PM640KBPDF document
"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.

DeviceSoftwareLast seen
MacBook PromacOS 26.1Now
iPhone 17 ProiOS 26.12 minutes ago
iPad AiriPadOS 26.0Yesterday
Mac StudiomacOS 26.1Nov 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

PropTypeDefaultDescription
selectionMode"none" | "single" | "multiple""none"How many rows can be selected.
value / defaultValuestring[]—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.
alternatingbooleanfalseShades every other row.

TableColumn

PropTypeDefaultDescription
valuestring—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

PropTypeDefaultDescription
valuestring—TableRow: identifies the row in the selection.
align"start" | "center" | "end""start"TableCell: aligns the cell's content.
secondarybooleanfalseTableCell: 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 variableOnMeaning
data-selection-mode.mb-tablenone, single or multiple.
data-alternating.mb-tableEvery other row is shaded.
data-selected.mb-table-rowThe row is selected.
data-sorted.mb-table-columnascending or descending on the sorted column.
data-align.mb-table-column, .mb-table-cellThe cell's alignment.
--mb-table-row-height, --mb-table-header-heightplatformRow and header heights.
--mb-table-inset, --mb-table-row-radiusplatformHow far the selection sits from the edges, and its corners.