Table
Structural primitive for tabular data. Eight compound parts (Table / TableHeader / TableBody / TableRow / TableHead / TableCell / TableFooter / TableCaption) so every list surface in the Dashboard reads with the same rhythm. Yuno spec (per Figma): header row 40px tall with bg-sidebar + text-sm font-medium text-muted-foreground (never uppercase), body cells 52px tall (h-13) with p-2 + text-sm text-foreground, 1px border between rows, hover:bg-muted/40, selectable rows via data-state='selected'.
Anatomy
Eight compound parts. Table is the outer scroll-wrapped <table>; TableHeader / TableBody / TableFooter are the thead / tbody / tfoot semantic groups; TableRow is a <tr> with hover + selected states; TableHead is the header cell (40px, bg-sidebar, muted foreground); TableCell is the body cell (52px, text-foreground); TableCaption is the caption below the table.
| Name | Type | Status | Value |
|---|---|---|---|
| Item one | Type A | Active | 128 |
| Item two | Type B | Pending | 64 |
| Item three | Type A | Active | 32 |
- 1Table (Root)
Wraps the <table> in a scroll container (<div className='relative w-full overflow-auto'>) so wide tables scroll horizontally without breaking the page layout. w-full caption-bottom text-sm.
- 2TableHeader
The <thead>. Applies border-b to every <tr> inside so the header row has a rule below it.
- 3TableHead
The header cell (<th>). Yuno Figma spec: h-10 bg-sidebar px-2 text-sm font-medium text-muted-foreground text-left align-middle. Never uppercase. Can hold an Info icon on the left and a sort ArrowDown on the right (gap-2).
- 4TableBody
The <tbody>. Strips the border-b from the last <tr> so the table ends flush with its outer border.
- 5TableRow
The <tr>. Ships border-b border-border + transition-colors + hover:bg-muted/40 + data-[state=selected]:bg-muted. Set data-state='selected' to keep a row highlighted after selection (e.g. inspector open).
- 6TableCell
The body cell (<td>). Yuno Figma spec: h-13 (52px) p-2 (8px) align-middle text-sm text-foreground text-left. All content stays left-aligned — for numeric columns add tabular-nums so the digits align vertically without right-aligning.
- 7TableFooter
The <tfoot>. border-t bg-muted/50 font-medium. Use for totals or summary rows.
- 8TableCaption
The <caption> below the table. mt-4 text-sm text-muted-foreground. Rare in the Dashboard — reserve for accessibility captions when the surrounding heading isn't clear.
Variants
One primitive, several arrangements. Header cells can hold a plain title, a leading Info icon, a trailing sort ArrowDown, or both. Body cells can be plain text, numeric (tabular-nums for digit alignment — still left-aligned), or badge/status pills. Row height stays 52px so all lists in the Dashboard match. First and last cells across both header and body get 24px padding on the outer edge automatically (via first:pl-6 last:pr-6) — the gutter keeps content off the border.
| Name | Type | Status |
|---|---|---|
| Item one | Type A | Active |
<TableHead>Column title</TableHead>| Value | Type |
|---|---|
| 128 | Type A |
<TableHead>
<span className="inline-flex items-center gap-2">
<Tooltip>
<TooltipTrigger>
<Info weight="light" className="size-4" />
</TooltipTrigger>
<TooltipContent>Explain the column</TooltipContent>
</Tooltip>
Column title
</span>
</TableHead>| Type | |
|---|---|
| Item one | Type A |
<TableHead>
<button type="button" className="inline-flex items-center gap-2">
Column title
<ArrowDown weight="light" className="size-4" />
</button>
</TableHead>| Type | |
|---|---|
| 128 | Type A |
<TableHead>
<span className="inline-flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label="About this column">
<Info weight="light" className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Short helper text.</TooltipContent>
</Tooltip>
<button type="button" className="inline-flex items-center gap-2">
Column title
<ArrowDown weight="light" className="size-4" />
</button>
</span>
</TableHead>| Name | Value |
|---|---|
| Item one | 128 |
| Item two | 64 |
| Item three | 32 |
<TableHead>Value</TableHead>
<TableCell className="tabular-nums">{value}</TableCell>
// Content stays LEFT-aligned per Yuno spec.
// tabular-nums locks digit widths so numbers line up vertically.States
Row: idle → hover:bg-muted/40 (subtle) → data-state='selected':bg-muted (persistent). Header row inherits bg-sidebar so it separates from the body without a stripe. There is no active/focused cell state — that's the row hover.
| Name | Type | Value |
|---|---|---|
| Idle row | Type A | 128 |
| Selected row | Type B | 64 |
| Idle row | Type C | 32 |
Recipes
Ready-to-copy compositions covering the most common Yuno usages of this atom.
The canonical Dashboard table: header row with plain titles, body rows with mixed types, a right-aligned numeric column. Wrap in a rounded-lg border to give it a Card surface.
| Name | Type | Status | Value |
|---|---|---|---|
| Item one | Type A | Active | 128 |
| Item two | Type B | Pending | 64 |
| Item three | Type A | Active | 32 |
| Item four | Type C | Archived | 16 |
<div className="w-full overflow-hidden rounded-lg border bg-card">
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Status</TableHead>
<TableHead>Value</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((r) => (
<TableRow key={r.name}>
<TableCell>{r.name}</TableCell>
<TableCell>{r.type}</TableCell>
<TableCell>{r.status}</TableCell>
<TableCell className="tabular-nums">{r.value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>Add a Phosphor ArrowDown next to the header text to signal sort state. Rotate 180° for ascending (via rotate-180) or keep down for descending. Wire the header to your sort handler.
| Name | |
|---|---|
| Item one | 128 |
| Item two | 64 |
| Item three | 32 |
<TableHead>
<button
type="button"
onClick={() => setSortDir((d) => (d === "asc" ? "desc" : "asc"))}
className="inline-flex items-center gap-2"
>
Value
<ArrowDown
weight="light"
className={cn("size-4 transition-transform", sortDir === "asc" && "rotate-180")}
/>
</button>
</TableHead>Prepend a Phosphor Info icon inside the header cell + a Tooltip to explain what the column means. Common for metric columns whose formula isn't obvious.
| Name | Value |
|---|---|
| Item one | 128 |
<TableHead>
<span className="inline-flex items-center gap-2">
<Tooltip>
<TooltipTrigger asChild>
<button type="button" aria-label="About this column">
<Info weight="light" className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent>Short helper text.</TooltipContent>
</Tooltip>
Value
</span>
</TableHead>Set data-state='selected' on the row you want to keep highlighted while an inspector Sheet is open. The row picks up bg-muted (stronger than the hover tint) so the user tracks which record they're editing.
| Name | Type |
|---|---|
| Item one | Type A |
| Item two (selected) | Type B |
| Item three | Type C |
<TableRow data-state="selected">
{/* cells */}
</TableRow>The trailing action column always uses one of two documented cell types (see 'Cell types' below). Eye + Tooltip when the ONLY row-level action is 'view details'. Dots (DotsThreeOutline fill) + Tooltip + DropdownMenu when the row supports 2+ actions (edit / duplicate / delete / open). Both share the same 'Icon-only with tooltip' Button treatment and both must be sticky right-0 border-l bg-card so they stay visible when the table scrolls horizontally.
| Name | Type | Single | Multiple |
|---|---|---|---|
| Item one | Type A | ||
| Item two | Type B | ||
| Item three | Type A |
{/* Reuse the two documented cell types — see "Cell types" below.
Eye when the ONLY action is view-details.
Dots + DropdownMenu when there are 2+ actions per row. */}
<TableCell className="sticky right-0 z-10 w-10 pl-4 pr-4! border-l border-border bg-card">
{/* single: Eye + Tooltip */}
{/* multiple: Dots + Tooltip + DropdownMenu */}
</TableCell>When the table exceeds its container width, the outer wrapper's overflow-auto lets it scroll horizontally. Reuse either documented action cell type — 'Eye action (single)' or 'Dots action (multiple)' from Cell types below — and add sticky right-0 z-10 border-l bg-card to BOTH the TableHead and TableCell of the action column. The border-l acts as the visible seam; the bg-card layer prevents scrolling content from bleeding through.
| Name | Type | Status | Extra column 1 | Extra column 2 | Extra column 3 | Extra column 4 | |
|---|---|---|---|---|---|---|---|
| Item one | Type A | Active | Value A | Value B | Value C | Value D | |
| Item two | Type B | Pending | Value A | Value B | Value C | Value D | |
| Item three | Type A | Active | Value A | Value B | Value C | Value D |
← scroll horizontally · the eye column stays pinned right →
{/* When the table has more columns than the container can show,
the outer overflow-auto scrolls horizontally.
Reuse either documented cell type (see "Cell types" below):
- Eye action (single: view details)
- Dots action (multiple: DropdownMenu)
Both must be sticky right-0 with border-l bg-card. */}
<TableHead className="sticky right-0 z-10 w-10 pl-4 pr-4! border-l border-border bg-sidebar" />
{/* Eye — single action */}
<TableCell className="sticky right-0 z-10 w-10 pl-4 pr-4! border-l border-border bg-card">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="View details"
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent text-muted-foreground transition-all hover:border-border hover:bg-background hover:text-foreground hover:shadow-xs"
>
<Eye weight="light" className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>View details</TooltipContent>
</Tooltip>
</TableCell>
{/* Dots — multiple actions (see cellTypes.dotsAction for the full snippet) */}When the table supports bulk actions (delete selected, export, tag) add a leading checkbox column: a select-all Checkbox in the header + a per-row Checkbox in each body cell. The header Checkbox uses the 3-state pattern (checked / indeterminate / unchecked) and toggles ALL rows. Mirror the sticky rule from the action column but pin LEFT: sticky left-0 z-10 border-r border-border bg-card + w-10 pl-4! pr-4 (16px each side, override the primitive's first:pl-6 rule). Gutter shift: when a checkbox column is first, the 24px outer gutter shifts to the SECOND column (Name) — add pl-6 to it manually. Same on the right when there's an action column: add pr-6 to the second-to-last data column.
| Name | Type | Status | Extra column 1 | Extra column 2 | Extra column 3 | Extra column 4 | ||
|---|---|---|---|---|---|---|---|---|
| Item one | Type A | Active | Value A | Value B | Value C | Value D | ||
| Item two | Type B | Pending | Value A | Value B | Value C | Value D | ||
| Item three | Type A | Active | Value A | Value B | Value C | Value D | ||
| Item four | Type C | Archived | Value A | Value B | Value C | Value D |
1 of 4 selected · click the header checkbox to toggle all
{/* Header Checkbox = select-all (with indeterminate state).
Row Checkbox = per-row toggle. */}
const [checked, setChecked] = React.useState<Set<string>>(new Set());
const allChecked = checked.size === rows.length;
const someChecked = checked.size > 0 && checked.size < rows.length;
const headerState = allChecked ? true : someChecked ? "indeterminate" : false;
const toggleAll = () => {
setChecked(allChecked ? new Set() : new Set(rows.map((r) => r.id)));
};
const toggleRow = (id: string) => {
setChecked((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
<TableHead className="sticky left-0 z-10 w-10 pl-4! pr-4 border-r border-border bg-sidebar">
<Checkbox
checked={headerState}
onCheckedChange={toggleAll}
aria-label="Select all rows"
/>
</TableHead>
<TableCell className="sticky left-0 z-10 w-10 pl-4! pr-4 border-r border-border bg-card">
<Checkbox
checked={checked.has(row.id)}
onCheckedChange={() => toggleRow(row.id)}
aria-label={`Select ${row.name}`}
/>
</TableCell>Cell types
Not every cell is plain text. Below are the canonical cell types used across the Yuno Dashboard so a PM can copy the right shape when a column shows more than a label. All still follow the two Yuno cell rules: left-aligned content, and 24px outer gutter on the first / last column.
| Type | Example cell |
|---|---|
| Text | Item name |
| Numeric | 1,284 |
| Currency amount | 1,284.50USD |
| Two lines (title + subtitle) | Primary line Secondary line |
| Date / time (Yuno format) | Feb 8, 2023, 10:00 a.m. |
| Status badge | Succeeded |
| Progress | 68% |
| User (avatar + name) | A Alex Doe alex@example.com |
| Provider (logo + name) | |
| Country (flag + name) | |
| ID / link | |
| Button outline sm | |
| Switch | |
| Input (inline edit) | |
| Eye action (single) | |
| Dots action (multiple) |
TextNames, labels, descriptions. The default.
<TableCell>{value}</TableCell>NumericCounts, values without a currency. tabular-nums lines up digits vertically without right-aligning.
<TableCell className="tabular-nums">{value}</TableCell>Currency amountMonetary values. Amount + currency code in the same cell, tabular-nums for digit alignment.
<TableCell className="tabular-nums">
<span className="font-medium text-foreground">{amount}</span>
<span className="ml-1 text-muted-foreground">{currency}</span>
</TableCell>Two lines (title + subtitle)Generic two-row cell — a primary line and a secondary supporting line. Any column where a label + qualifier reads better as a stack than inline (a name + role, a value + delta, an id + last-seen). Primary text-foreground + secondary text-xs text-muted-foreground.
<TableCell>
<div className="text-foreground">{primary}</div>
<div className="text-xs text-muted-foreground">{secondary}</div>
</TableCell>Date / time (Yuno format)Dates and times inline in ONE cell — this is how Yuno backend sends them, so no two-line split. Same text-sm text-foreground as a plain Text cell. Formats: date-only 'Dic 14, 2023' · time-only '8:00 p.m.' or '12:00 m.' (noon) · combined 'Feb 8, 2023, 10:00 a.m.'.
<TableCell>{dateTime}</TableCell>
// Backend sends the string pre-formatted. Just render it — no client-side formatting.
// Formats:
// date-only: Dic 14, 2023
// time-only: 8:00 p.m. or 12:00 m. or 5:00 p.m.
// combined: Feb 8, 2023, 10:00 a.m.Status badgeAny state that reads as a discrete label — transactions, connections, users, invoices. Uses the StatusBadge preset.
<TableCell>
<StatusBadge status="succeeded" />
</TableCell>ProgressCapacity, completion, or impact percentage. Pair the bar with the numeric value on its right.
<TableCell>
<div className="flex items-center gap-3">
<Progress value={value} className="h-1.5 w-20" />
<span className="text-sm tabular-nums text-muted-foreground">{value}%</span>
</div>
</TableCell>User (avatar + name)Any row that represents a person — team members, transaction customer, comment author. Optional email/subtitle below the name.
<TableCell>
<div className="flex items-center gap-3">
<Avatar size="sm">
<AvatarFallback>{name[0]}</AvatarFallback>
</Avatar>
<div>
<div className="text-foreground">{name}</div>
<div className="text-xs text-muted-foreground">{email}</div>
</div>
</div>
</TableCell>Provider (logo + name)Rows that reference a payment provider (Stripe, Adyen, dLocal, PayU, etc). Kit convention: pull the logo from Yuno's icon CDN — https://icons.prod.y.uno/{provider}_logosimbolo.png — swap the provider slug and you get the correct logo. 16×16 rendered (size-4 token), 8px gap, provider name in text-sm.
<TableCell>
<div className="flex items-center gap-2">
<img
src={`https://icons.prod.y.uno/${providerSlug}_logosimbolo.png`}
alt=""
className="size-4"
/>
<span>{providerName}</span>
</div>
</TableCell>
// providerSlug is the provider's lowercase name — stripe / adyen / dlocal / payu / mercadopago / etc.Country (flag + name)Rows that reference a country — merchant location, transaction origin, currency country. 16×16 circular flag (size-4 token) on the left, 8px gap, country name inherits the default cell typography (text-sm text-foreground, font-normal — same as a plain Text cell). Kit convention: circle-flags CDN (HatScripts, MIT) — SVGs are already circular, zero dependencies, PMs just swap the ISO-2 country code in the URL.
<TableCell>
<div className="flex items-center gap-2">
<img
src={`https://hatscripts.github.io/circle-flags/flags/${countryCode}.svg`}
alt=""
className="size-4"
/>
<span>{countryName}</span>
</div>
</TableCell>
// countryCode is the ISO 3166-1 alpha-2 in lowercase — br / us / ar / mx / cl / co / pe / etc.
// The name inherits the default cell typography — no font-medium override.ID / linkClickable IDs (transaction ID, rule ID, invoice number). Renders as a Button link that opens the detail Sheet.
<TableCell>
<button type="button" className="text-primary hover:underline">
{id}
</button>
</TableCell>Button outline smInline row-level action that isn't hidden behind a menu — e.g. Retry / Approve / Resend. Use variant='outline' size='sm' so it stays compact inside the 52px row.
<TableCell>
<Button variant="outline" size="sm">Action</Button>
</TableCell>SwitchPer-row on/off toggle — enabling a rule, activating a webhook, marking a row as visible. Radix Switch inline; wire onCheckedChange to your handler.
<TableCell>
<Switch checked={value} onCheckedChange={handleToggle} />
</TableCell>Input (inline edit)Editable cell inside the table — quick per-row edits (rename, tweak an amount, set a label). Use the compact h-8 Input so it fits inside the 52px row. Focus reveals the ring; hover surface is intentionally subtle so idle rows don't look busy.
<TableCell>
<Input
value={value}
onChange={(e) => handleChange(e.target.value)}
className="h-8"
/>
</TableCell>Eye action (single: view details)Trailing action column when the ONLY row-level action is 'view details'. Same 'Icon-only with tooltip' treatment from Button — no dropdown, direct action. Tooltip says 'View details' (or equivalent). The sticky right-0 pin keeps the icon visible when the table scrolls horizontally — do the same in the matching TableHead.
<TableCell className="sticky right-0 z-10 w-10 pl-4 pr-4! border-l border-border bg-card">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label="View details"
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent text-muted-foreground transition-all hover:border-border hover:bg-background hover:text-foreground hover:shadow-xs"
>
<Eye weight="light" className="size-4" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>View details</TooltipContent>
</Tooltip>
</TableCell>Dots action (multiple: DropdownMenu)Trailing action column when the row supports MULTIPLE actions — Edit / Delete / Duplicate / Open. Same 'Icon-only with tooltip' Button treatment, but the button is a DropdownMenuTrigger. Icon is DotsThreeOutline (fill). The sticky right-0 pin keeps the icon visible when the table scrolls horizontally — do the same in the matching TableHead.
<TableCell className="sticky right-0 z-10 w-10 pl-4 pr-4! border-l border-border bg-card">
<DropdownMenu>
<Tooltip>
<TooltipTrigger asChild>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label="Actions"
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-transparent bg-transparent text-muted-foreground transition-all hover:border-border hover:bg-background hover:text-foreground hover:shadow-xs data-[state=open]:border-border data-[state=open]:bg-background data-[state=open]:text-foreground"
>
<DotsThreeOutline weight="fill" className="size-4" />
</button>
</DropdownMenuTrigger>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>Actions</TooltipContent>
</Tooltip>
<DropdownMenuContent align="end">
<DropdownMenuItem>Edit</DropdownMenuItem>
<DropdownMenuItem>Duplicate</DropdownMenuItem>
<DropdownMenuItem>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>Import
Full compound import from @/components/ui/table.
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";Props
Every part forwards standard HTML table props (<th>, <td>, <tr>, etc). The most useful in prototypes:
| Prop | Type | Default | Description |
|---|---|---|---|
| Table.className | string | — | Merged onto the inner <table>. Do not use it to control width — the outer scroll wrapper handles that. |
| TableHead.className | string | — | Merged onto the <th>. Common overrides: w-10 (icon/action column), tabular-nums (numeric column header). Do not remove bg-sidebar — it's the Yuno rhythm. NEVER use text-right — Yuno spec is all content left-aligned. |
| TableCell.className | string | — | Merged onto the <td>. Common overrides: tabular-nums (numeric column, for consistent digit width — still left-aligned), w-10 (action column). Never override the h-13 height. NEVER use text-right — Yuno spec is all content left-aligned. |
| TableRow (data-state) | "selected" | — | Set data-state='selected' to keep a row highlighted (bg-muted, stronger than the hover tint). Used when an inspector Sheet is open so the user tracks which record they're editing. |
| TableRow.onClick | (event) => void | — | Optional click handler on the whole row. Common pattern: open a Sheet with the row's detail. Pair with hover:bg-muted/40 (already default) for a visible affordance. |
| TableHead / TableCell colSpan | number | — | Standard HTML colSpan. Use for header groups (TableHead with colSpan={4}) or spanning empty-state cells across the body. |
When to use
- Any Dashboard list where column alignment matters (transactions, rules, connections, teams, invoices).
- Reports, ledgers, and structured records where users need to compare rows side by side.
- Any surface where the data is genuinely tabular — one row per record, one column per field.
When not to use
- Key-value pairs (single record with named fields) — use a description list.
- A flat vertical list of items with a single label — use a Card list or a Sheet body.
- Content that reads better as cards (image + copy + CTA) — Table forces horizontal comparison and it fights image-led content.
Usage
- Keep the header at h-10 and body cells at h-13 — the Yuno rhythm.
- Left-align every column, including numeric ones. Use tabular-nums on numeric cells so the digits line up vertically without right-aligning the content.
- The first and last cell's 24px outer padding (first:pl-6 last:pr-6) is baked in — don't override it or the content will kiss the outer border.
- Add hover:bg-muted/40 (already the default) so the pointer position is obvious across wide tables.
- Wrap the whole Table in a rounded-lg border to give it a Card surface — the primitive doesn't ship this.
- Use TableCaption or a heading above the table so screen readers announce what it contains.
- Don't right-align any cell content — Yuno spec is left-aligned across every column (numeric columns included). Use tabular-nums for digit alignment instead of text-right.
- Don't uppercase the header text — the Yuno spec (per Figma) is text-sm font-medium normal case.
- Don't stripe rows — the hover state covers the same purpose without visual noise.
- Don't inflate the body row past h-13 (52px) — the rhythm breaks and dense lists get too tall.
- Don't embed full-size Inputs inside cells — either drop into a Sheet to edit or use a compact inline treatment.
- Don't skip the outer wrapper — a bare <Table> without a border reads as raw HTML.
- Don't override the first/last column's 24px padding — it's the gutter that keeps content off the outer border.
Related
Cross-links to atoms and patterns you may reach for next.
- PaginationEvery table over ~20 rows gets Pagination — the canonical Dashboard pattern. Space it 24px below the table (mt-6) — Pagination is not part of the Table primitive, it lives as a sibling under it.
- Scroll areaWrap wide tables in a horizontal ScrollArea so the page itself doesn't force horizontal scroll.
- CheckboxFor row-selection tables, put a Checkbox in the first cell and the same in the header (select-all).
- Dropdown menuRow-level actions live in a DropdownMenu triggered by an icon-only Button in the last column.
- SkeletonWhile the rows load, skeleton the tbody cells — keep the thead labels visible so the user reads the shape of the data.