Table
Rows of like things: services, bookings, staff. Framed on its own glass surface, or plain inside a card; it scrolls sideways when it runs out of room. Built on the vendored shadcn table.
Playground
| Stylist | Bookings | Revenue |
|---|---|---|
| Anna Kováčová | 14 | €742.00 |
| Marek Horváth | 19 | €542.00 |
| Total | 33 | €1,284.00 |
import {
Table, TableHeader, TableBody, TableRow, TableHead, TableCell, TableCaption, TableFooter,
} from "@/components/shared/table";
<Table>
<TableCaption>Bookings and revenue per stylist, this week</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Stylist</TableHead>
<TableHead numeric>Bookings</TableHead>
<TableHead numeric>Revenue</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{staff.map((s) => (
<TableRow key={s.name}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell numeric>{s.bookings}</TableCell>
<TableCell numeric>{s.revenue}</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell numeric>33</TableCell>
<TableCell numeric>€1,284.00</TableCell>
</TableRow>
</TableFooter>
</Table>Framed
The default: the table brings its own glass squircle surface, as on the services page. Controls in a row stay small. Use an xs button and a small switch, so every row keeps one height. Toggle a service.
| Service | Duration | Buffer | Price | Active | Actions |
|---|---|---|---|---|---|
| Men's haircut | 30 min | 5 min | €22.00 | ||
| Haircut & beard | 45 min | 10 min | €32.00 | ||
| Beard trim | 20 min | 5 min | €15.00 | ||
| Balayage | 150 min | 15 min | €120.00 | ||
| Kids' haircutInactive | 20 min | 5 min | €14.00 |
import {
Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption,
} from "@/components/shared/table";
<Table>
<TableHeader>
<TableRow>
<TableHead>Service</TableHead>
<TableHead numeric>Duration</TableHead>
<TableHead numeric>Buffer</TableHead>
<TableHead numeric>Price</TableHead>
<TableHead className="text-center">Active</TableHead>
<TableHead><span className="sr-only">Actions</span></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{services.map((s) => (
<TableRow key={s.id} className={s.active ? undefined : "text-muted-foreground"}>
<TableCell className="font-medium">{s.name}</TableCell>
<TableCell numeric>{s.durationMin} min</TableCell>
<TableCell numeric>{s.bufferMin} min</TableCell>
<TableCell numeric>{formatEur(s.price)}</TableCell>
<TableCell className="text-center">
<Switch size="sm" checked={s.active} onCheckedChange={…} aria-label={`Active: ${s.name}`} />
</TableCell>
<TableCell className="text-right">
<Button variant="secondary" size="xs" onClick={…}>Edit</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>Plain, in a card
When a table needs a title, put it in a Card as a direct child, not inside CardContent, and make it plain. Rows then run edge to edge, and the cells' 16px inset lines up with the card's header.
import {
Table, TableHeader, TableBody, TableFooter, TableRow, TableHead, TableCell, TableCaption,
} from "@/components/shared/table";
import { Card, CardHeader, CardTitle, CardDescription } from "@/components/shared/card";
<Card flush>
<CardHeader>
<CardTitle>Today</CardTitle>
<CardDescription>Tuesday 13 October · 4 bookings</CardDescription>
</CardHeader>
<Table variant="plain">
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Client</TableHead>
<TableHead>Service</TableHead>
<TableHead>Code</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{bookings.map((b) => (
<TableRow key={b.id}>
<TableCell className="tabular-nums">{b.time}</TableCell>
<TableCell className="font-medium">{b.customerName}</TableCell>
<TableCell>{b.serviceName}</TableCell>
<TableCell><Badge variant="outline">{b.bookingCode}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Card>A row that opens
| Name | Bookings | Reminders |
|---|---|---|
| 14 | ||
| 19 |
Click anywhere on a row.
<TableRow onOpen={() => open(client)}>
<TableCell>
<button type="button" onClick={() => open(client)}>{client.name}</button>
</TableCell>
…
</TableRow>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | "framed" | "plain" | "framed" | Table only. Framed stands on its own glass squircle surface; plain has none, for a table placed directly in a Card. |
| numeric | boolean | false | TableHead and TableCell. Right-aligned, in tabular figures, for durations, prices and counts. Set it on the header too. |
| onOpen | () => void | — | TableRow only. The row opens what it is from a click anywhere on it, with a pointer and a hover wash. Its own buttons, links, switches and menus keep their clicks; keep a button in the row (the name, or Edit) for a keyboard. |
| className | string | — | Layout on any part: a min width on Table, alignment or weight on a cell, a muted row. Never restyle the surface or the rules. |
The parts are the table's own elements: TableCaption, TableHeader, TableBody, TableFooter (a porcelain band for totals), TableRow, TableHead and TableCell. Each passes everything else through to its element.
Guidelines
- Use a table when rows are compared across columns. A list of things each read on its own, like a day's bookings on a phone, is a list, not a table.
- Numbers are
numeric: right-aligned in tabular figures, header included, so digits line up down the column. Put the unit in the cell (“45 min”, “€32.00”) rather than the header. - A row that opens something — a client, a member, a service — opens from anywhere on it: give it
onOpen(Jozef, 2026-10-23). Other rows don't light up on hover. Any other action goes in its own cell as anxsbutton; name icon-only ones. - Give every table a name: a
TableCaption, or the title of the card it sits in, linked witharia-labelledby. Hidden headers, like an actions column, getsr-onlytext. - Show an inactive row muted and badged, never faded. Faded rows fade their controls too, and read as disabled.