Table
A data table — shadcn's Table restyled to the Acrylic material: flat and transparent so it composes inside a Card, with hairline row separators, muted column labels, and a spring-wired hover tint.
The shadcn Table, recolored to the Acrylic palette. The anatomy and API are
copied 1:1 from shadcn; only the material changes — the table is flat and
transparent (no surface of its own), rows are divided by inset hairlines, column
labels take the small muted subheadline style, and — like the rest of the kit's
rows (menus, command, items) — the row highlight is the macOS inset-rounded pill
rather than a full-bleed tint: a rounded fill that hovers neutral and goes solid
accent with a white label when selected, riding the same spring substrate. Drop it inside a
Card when you want a surface behind it (the surface's padding is what insets
the highlight).
Installation
npx shadcn add https://acrylic-ui.vercel.app/r/table.jsonUsage
"use client"import * as React from "react"import { Card } from "@/registry/acrylic/card"import { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow,} from "@/registry/acrylic/table"const invoices = [ { plan: "iCloud+", detail: "2 TB storage", period: "Monthly", amount: "$9.99" }, { plan: "Apple Music", detail: "Individual", period: "Monthly", amount: "$10.99" }, { plan: "Apple TV+", detail: "Family sharing", period: "Monthly", amount: "$9.99" }, { plan: "Apple Arcade", detail: "200+ games", period: "Monthly", amount: "$6.99" },]export default function TableDemo() { // Click a row to select it — selection is shadcn's contract, unchanged: // data-state="selected" is what swaps the neutral hover pill for the accent one. const [selected, setSelected] = React.useState("Apple Music") return ( <Card className="w-full max-w-xl p-2 text-foreground"> <Table> <TableCaption>Your Apple subscriptions this month.</TableCaption> <TableHeader> <TableRow> <TableHead>Service</TableHead> <TableHead>Detail</TableHead> <TableHead>Billing</TableHead> <TableHead className="text-right">Amount</TableHead> </TableRow> </TableHeader> <TableBody> {invoices.map((row) => ( <TableRow key={row.plan} data-state={selected === row.plan ? "selected" : undefined} aria-selected={selected === row.plan} tabIndex={0} className="cursor-default outline-none focus-visible:[&>td]:bg-[var(--acr-hover)]" onClick={() => setSelected(row.plan)} onKeyDown={(event) => { if (event.key === "Enter" || event.key === " ") { event.preventDefault() setSelected(row.plan) } }} > <TableCell className="font-medium">{row.plan}</TableCell> <TableCell className="text-muted-foreground group-data-[state=selected]/table-row:text-primary-foreground/70!"> {row.detail} </TableCell> <TableCell className="text-muted-foreground group-data-[state=selected]/table-row:text-primary-foreground/70!"> {row.period} </TableCell> <TableCell className="text-right">{row.amount}</TableCell> </TableRow> ))} </TableBody> <TableFooter> <TableRow> <TableCell colSpan={3}>Total</TableCell> <TableCell className="text-right">$37.96</TableCell> </TableRow> </TableFooter> </Table> </Card> )}Click a row in the preview to select it, and hover a resting row alongside it to
compare the two fills — selection is shadcn's contract unchanged,
data-state="selected" on the TableRow.
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/acrylic/table"
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>iCloud+</TableCell>
<TableCell className="text-right tabular-nums">$9.99</TableCell>
</TableRow>
<TableRow data-state="selected">
<TableCell>Apple Music</TableCell>
<TableCell className="text-right tabular-nums">$10.99</TableCell>
</TableRow>
</TableBody>
</Table>Sticky header
<TableHeader sticky> turns the column labels into floating chrome the rows
scroll under: the kit's surface material + blur, and the bar's outer top corners
take the same 7px the row pill uses, so it doesn't read as a square block inside
a rounded surface. Scroll the list below.
import { Card } from "@/registry/acrylic/card"import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from "@/registry/acrylic/table"const tracks = [ { n: 1, title: "Ventura Highway", album: "Homecoming", time: "3:31" }, { n: 2, title: "Tin Man", album: "Holiday", time: "3:28" }, { n: 3, title: "Sister Golden Hair", album: "Hearts", time: "3:19" }, { n: 4, title: "A Horse with No Name", album: "America", time: "4:08" }, { n: 5, title: "I Need You", album: "America", time: "3:07" }, { n: 6, title: "Lonely People", album: "Holiday", time: "2:29" }, { n: 7, title: "Daisy Jane", album: "Hearts", time: "3:09" }, { n: 8, title: "Woman Tonight", album: "Hearts", time: "2:39" }, { n: 9, title: "Only in Your Heart", album: "Homecoming", time: "3:02" }, { n: 10, title: "Don't Cross the River", album: "Homecoming", time: "2:31" },]export default function TableSticky() { return ( <Card className="w-full max-w-xl p-2 text-foreground"> {/* The Card's padded, rounded box is the scroll container — so the table renders bare (no wrapper of its own) and the header sticks to THIS. */} <div className="scrollbar-mac max-h-64 overflow-y-auto"> <Table scrollable={false}> <TableHeader sticky> <TableRow> <TableHead className="w-10 text-center">#</TableHead> <TableHead>Title</TableHead> <TableHead>Album</TableHead> <TableHead className="w-16 text-right">Time</TableHead> </TableRow> </TableHeader> <TableBody> {tracks.map((t) => ( <TableRow key={t.n}> <TableCell className="text-center text-muted-foreground">{t.n}</TableCell> <TableCell className="font-medium">{t.title}</TableCell> <TableCell className="text-muted-foreground">{t.album}</TableCell> <TableCell className="text-right text-muted-foreground">{t.time}</TableCell> </TableRow> ))} </TableBody> </Table> </div> </Card> )}Sticky needs the right scroll container. Table normally wraps itself in a
horizontally-scrollable div — and that div, not the page, is what a sticky <th>
would stick to, which looks like sticky is simply broken. Render the table bare
inside your own scroll box: <Table scrollable={false}>.
Anatomy
Table— wraps the<table>in a horizontally scrollable container with the macOS overlay scrollbar (scrollbar-mac).scrollable={false}drops that wrapper (an acrylic divergence from shadcn) for when an outer box already scrolls — required for a sticky header.TableHeader/TableBody/TableFooter—<thead>/<tbody>/<tfoot>. The header takessticky(see above). The footer carries a faint chip fill and medium weight for totals.TableRow— a row separated by an inset hairline. On hover it shows the macOS inset-rounded highlight (neutral--acr-hover); setdata-state="selected"for the solid accent selection (bg-primary+text-primary-foregroundon every cell). A highlighted row drops its own separator and the one above it; the last body row has none to begin with. The row also carriesgroup/table-row, so a descendant can react to selection (see Notes).TableHead— a column-label cell: muted, small subheadline size.TableCell— a body cell.TableCaption— a muted caption below the table.
Notes
- It has no surface of its own. For a framed look, put it inside a
Card(see the preview). On a bare page it renders flat. - Right-align numeric columns with
className="text-right"on both theTableHeadand theTableCell. SF's proportional figures are the Apple default; reach fortabular-numsonly in dense numeric grids where strict digit alignment actually matters. - Colours are tokens — pass
classNamefor layout only, never to override the material. Light / dark / acrylic themes flip automatically. - Selected rows: set
data-state="selected"on aTableRow. The fill is solid--primaryand every cell's text is forced totext-primary-foreground— atext-muted-foregroundcolumn would be illegible on the accent, so the component overrides it rather than leaving it to the call site. Hover is suppressed on a selected row, so the neutral tint never fights the accent. - Dimming a secondary column while selected: the row carries
group/table-row, so opt a cell back down withgroup-data-[state=selected]/table-row:text-primary-foreground/70!— the!is required, since the component's own per-cell rule is more specific (this is what the preview's Detail and Billing columns do). - The highlight is inset by the container — because the rounded pill spans
the table width, it reads as "inset" only when the table has a padded surface
around it (a
Card, or a wrapper with a little horizontal padding), the same way a menu item is inset by its panel's padding.