Import
import {
Table,
TableBody,
TableCaption,
TableCell,
TableFooter,
TableHead,
TableHeader,
TableRow,
} from "@timelycare/helix-ui"
Structure
| Part | Tailwind |
|---|---|
| Table (container) | w-full overflow-x-auto + variant chrome (see Container variants); overflow-y-auto + an inline max-height with stickyHeader |
| Table | w-full caption-bottom text-sm |
| TableHeader | — (no fill); sticky top-0 z-10 bg-card when the enclosing Table has stickyHeader |
| TableRow | border-b border-border transition-colors [tbody_&]:hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 |
| TableHead | h-10 px-2 text-left align-middle text-sm font-semibold text-muted-foreground |
| TableCell | h-[52px] p-2 align-middle text-sm font-normal text-foreground — single-line rows are vertically centered; multi-line content opts into align-top per-cell |
| TableFooter | border-t font-medium |
| TableCaption | pt-4 text-sm text-muted-foreground text-center |
Container variants
A table always lives in a padded card so its rows are never flush to the edge. The variant prop controls whether the table is that card or sits inside one:
card(default) — the table renders as its own padded, bordered card (matching HelixCard:rounded-xl border bg-card shadow-sm, 24px padding). Use for a single table on a page that owns its surface; put the page header, controls, and footer around it, not inside:{/* page surface */} <PageHeader>…</PageHeader> <Filters /> <Table>…</Table> {/* variant="card" is the default */}flush— no chrome or padding; the surroundingCardprovides them. Use when several tables share a page and each is grouped with its own header/controls/footer inside one card:<Card> <CardHeader><CardTitle>Payment methods</CardTitle></CardHeader> <CardContent> <Table variant="flush">…</Table> </CardContent> <CardFooter>…</CardFooter> </Card>
Never put a card-variant table inside another Card, or inside a hand-rolled <div className="border rounded-lg"> — either double-borders (and the hand-rolled one mismatches radii too); use flush there, or drop the wrapper and let the table be the surface. In development, Table detects both arrangements in the DOM and logs a console warning naming the stacked classes: it checks for a CardContent ancestor, and for an immediate parent that has both a border width and a border radius. The parent check is deliberately one level deep — a bordered, rounded page frame further up (a section panel, a docs preview shell) is correct and stays silent. The type system cannot see any of this, so the notice is the only signal. TableHeader has no background fill in either variant (the one exception is stickyHeader, below).
Sticky headers
Long tables can pin their header row while the rows scroll. Table owns the scroll container, so both props go on Table:
<Table stickyHeader maxHeight="24rem">
<TableHeader>…</TableHeader> {/* pins itself; no prop needed here */}
<TableBody>{/* 200 rows */}</TableBody>
</Table>
| Prop | Type | Notes |
|---|---|---|
stickyHeader | boolean | Pins TableHeader to the top of the table's scroll panel. Adds sticky top-0 z-10 bg-card to the header — the fill is what stops rows showing through it. |
maxHeight | string (any CSS length) | Bounds the scroll panel, e.g. "24rem". Applied as an inline style, not a class, because the value is the caller's. Useful on its own for a table that should not stretch the page. |
stickyHeader requires maxHeight. Sticky positioning needs a bounded scroll container. The table container is already a scroll container — overflow-x-auto makes overflow-y compute to auto — but its height is unconstrained, so nothing ever scrolls inside it and the header has no scrollport to pin against. That is exactly why a hand-rolled sticky top-0 on TableHeader never worked. stickyHeader without maxHeight therefore does nothing and logs a dev-only warning rather than silently reproducing the bug. The one-axis-turns-on-the-other rule behind this is written up in CSS Behaviours That Have Bitten Us — read it before building any other component that scrolls sideways.
This is an inner scroll panel, not page-level sticky. The table gets its own bounded scrollbox; the page does not move, and the header does not park under an app bar. Parking a header under the app bar is a different UX and is not supported.
The card variant drops its vertical padding when sticky (py-0, horizontal inset kept). A scroll container's padding gutters sit inside its scrollport: with the full p-6, rows scrolled into the 24px top gutter stayed visible above the pinned header. The header's own h-10 supplies its breathing room, and rows clip against the container's rounded corners.
There is no containerClassName escape hatch: the height and the scroll container have to move together, and an arbitrary class on the container would sit outside the token rules.
Sizes
| Size | Cell Height | Use For |
|---|---|---|
| default | h-[52px] | Standard data tables |
| md | h-[72px] | Tables with more content |
| lg | h-[96px] | Tables with rich content (images, multi-line) |
Variants
Striped
Apply alternating row backgrounds by passing striped to TableBody. Odd-numbered rows (starting with row 1) receive bg-tc-gray-50 (light) / dark:bg-tc-gray-800; even rows carry no fill of their own and show the container surface through — bg-card on the card variant, whatever the parent provides on flush. (TableRow sets no background in any state but hover and selected; it never carried bg-input-bg.) The stripe uses the explicit tc-gray palette pair rather than a semantic token, to avoid unintended coupling. Use for dense, read-heavy tables where the eye needs help tracking across a row.
Row-level bg-* overrides do not work on a striped table, and that is deliberate. The stripe rule outranks a single class on a row, so a per-row background shows on even rows and is invisible on odd ones — meaning whether your colour appears depends on how many rows the data happened to return. Insert one row above and every highlight below it flips. Helix offers no per-row opt-out, because a row-wide colour wash is not how this system marks a row:
| You want to show | Use |
|---|---|
| The row under the cursor | Nothing — hover is bg-accent, built into TableRow, and already takes precedence over the stripe |
| A selected row | data-state="selected" — also bg-accent, also already takes precedence |
| A row's status (disputed, failed, in review) | A status pill in a cell. The label and tone carry the meaning; the row keeps its stripe |
| A total or summary | TableFooter — it renders <tfoot>, and the striped class sits on <tbody>, so footer rows are never striped |
If a table needs a row treatment none of these covers, that is a signal to document a new pattern — not to fight the stripe.
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody striped>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>{row.id}</TableCell>
<TableCell>{row.name}</TableCell>
<TableCell><Badge variant="outlineSage">{row.status}</Badge></TableCell>
</TableRow>
))}
</TableBody>
</Table>
Notes:
stripedcoexists with hover and selected states. The stripe rule excludes hovered/selected rows (:not(:hover):not([data-state=selected])), so the hover and selected backgrounds take precedence on odd rows too — in both light and dark mode. Just use the prop; don't hand-roll per-rowbg-*overrides.- Use striping only on
TableBodyrows; header and footer rows are unaffected.
Cell Variants
| Variant | Content | Use For |
|---|---|---|
| Default | Text only | Simple data |
| Bold Text | Title + description | Primary column, identifiers |
| Badge | Badge component | Status indicators |
| Avatar | Avatar + text | User columns |
| Switch | Switch toggle | Settings tables |
| Button | Action button | Row actions |
| Dropdown | DropdownMenu trigger with MoreVertical icon | Multiple actions |
| Progress | Progress bar | Completion status |
| Image | AspectRatio image | Media tables |
| Input | Input field | Editable cells |
| Toggle Group | Toggle buttons | Options selection |
States
| State | Implementation |
|---|---|
| Default | Base styling |
| Hover | [tbody_&]:hover:bg-accent on TableRow — body rows only, including striped odd rows (the stripe yields to hover). Header and footer rows do not highlight. |
| Focus (keyboard) | focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 — built into TableRow |
| Selected | bg-accent — same color as hover; selected rows have no additional hover state |
Common Patterns
Basic Table
<Table>
<TableCaption>A list of your recent invoices.</TableCaption>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-semibold">INV001</TableCell>
<TableCell>Paid</TableCell>
<TableCell className="text-right">$250.00</TableCell>
</TableRow>
</TableBody>
</Table>
With Footer
<Table>
<TableHeader>
<TableRow>
<TableHead>Invoice</TableHead>
<TableHead className="text-right">Amount</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* rows */}
</TableBody>
<TableFooter>
<TableRow>
<TableCell>Total</TableCell>
<TableCell className="text-right font-semibold">$2,500.00</TableCell>
</TableRow>
</TableFooter>
</Table>
Cell with Title + Description
<TableCell>
<div className="flex flex-col">
<span className="font-semibold">John Doe</span>
<span className="text-muted-foreground">john@example.com</span>
</div>
</TableCell>
Multi-line Cell Content
Cells default to align-middle with a h-[52px] minimum height, so single-line rows are vertically centered. Text wraps naturally — there is no whitespace-nowrap on cells. For multi-line content, constrain the column with max-w-* and add align-top so the wrapped text anchors to the top of the row. The h-[52px] acts as a floor — the row still expands to fit taller content.
<TableCell className="max-w-[260px] align-top">
A longer description that wraps across multiple lines. The row height
expands to fit the content; this cell opts into top alignment.
</TableCell>
Notes:
- Cells wrap text naturally (no
whitespace-nowrap). Addmax-w-*to control where a column wraps; addwhitespace-nowrapper-cell only if you need a specific column to stay on one line. - Single-line rows center on
align-middle; addalign-topper-cell for multi-line content so wrapped text anchors to the top. h-[52px]is a minimum, not a cap — rows with taller content grow past it.
Cell with Avatar
<TableCell>
<div className="flex items-center gap-2">
<Avatar className="size-8">
<AvatarImage src="/avatar.jpg" />
<AvatarFallback>JD</AvatarFallback>
</Avatar>
<span>John Doe</span>
</div>
</TableCell>
Cell with Badge
<TableCell>
<Badge variant="outlineSage">Active</Badge>
</TableCell>
Right-Aligned Column (Numbers)
<TableHead className="text-right">Amount</TableHead>
<TableCell className="text-right">$250.00</TableCell>
Table with Toolbar
Data tables should include filtering and actions above.
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center justify-between">
<Input placeholder="Filter..." className="max-w-sm" />
<div className="flex items-center gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<SlidersHorizontal className="size-4 mr-2" />
View
</Button>
</DropdownMenuTrigger>
{/* Menu content */}
</DropdownMenu>
<Button size="sm">
<Plus className="size-4 mr-2" />
Add
</Button>
</div>
</div>
{/* Table */}
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]"><Checkbox /></TableHead>
<TableHead>Name</TableHead>
<TableHead>Status</TableHead>
<TableHead className="w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{/* Rows */}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<p className="text-sm text-muted-foreground">0 of 10 selected</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled>Previous</Button>
<Button variant="outline" size="sm">Next</Button>
</div>
</div>
</div>
Badge in Table Cell
Status badges in table cells always use Helix outline primitive badge variants — never the shadcn default, secondary, destructive, or outline variants in table contexts.
<TableCell>
<Badge variant="outlineSage">Active</Badge>
</TableCell>
Status → Badge Variant Mapping
| Status | Badge Variant |
|---|---|
| Active / Success | outlineSage |
| Scheduled / Upcoming / Completed | outlineSage |
| Pending / In Progress | outlineNavy |
| Needs attention / Action required | outlineOrange |
| Error / Failed | outlineBerry |
| Cancelled | outlineBerry |
| No Show / Missed / Skipped | outlineNeutral |
| Inactive / Disabled | outlineNeutral |
Reserve berry for outcomes the system treats as failures — cancelled, failed, errored. A missed or skipped item is a neutral fact rather than an error, so it takes outlineNeutral; colouring it red reads as blame and makes real failures harder to spot in a scan.
outlineOrange is the tone for "someone needs to do something." It sits between navy (informational, no action) and berry (failed, nothing to do). Some products map states like Pending here when the wait is on a person rather than the system; the default above keeps Pending navy.
Accessibility
- Standard HTML table — uses semantic markup for accessibility
- Use
<thead>,<tbody>,<th>withscope="col"for proper structure - Screen reader: header/cell associations handled automatically with proper markup
- Clickable rows: add
tabIndex={0}to<TableRow>and handleonKeyDownfor Enter/Space — the focus ring is built in viafocus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50 - Sticky checkbox column: apply
sticky left-0 z-10 bg-card(or the row's active background) to both<TableHead>and<TableCell>in the checkbox column so selection state remains visible during horizontal scroll. Match the container surface —bg-card, notbg-input-bg: the two resolve to the same value today, but the table is a card surface, not an input - Add
role="grid"only if table is interactive
Gotchas
| Problem | Solution |
|---|---|
| Caption at top | Caption is always bottom by default |
| Missing hover | Ensure [tbody_&]:hover:bg-accent on TableRow, and that the rows sit inside TableBody |
| No keyboard focus ring | Add tabIndex={0} to the row — the ring is already in TableRow base styles and activates automatically |
| Sticky column bleeds | Set explicit bg-card (or the row's active bg) on sticky <TableHead> and <TableCell> — transparent sticky cells let scrolling content show through |
| Alignment issues | Use align-middle on cells |
| Last cell border | Remove with border-b-0 if needed |
| Wide tables | Already handled — the container is overflow-x-auto; do not add your own wrapper |
| Header scrolls away on a long table | <Table stickyHeader maxHeight="24rem"> — both props, on Table |
stickyHeader does nothing | maxHeight is missing, so the container never scrolls and there is nothing to stick within. The console says so in development |
| Rows show through the pinned header | The fill is bg-card, applied for you with stickyHeader. If you overrode the header's background, use an opaque token |
See Also
- Accessibility: Data Table Accessibility Requirements - Header associations, keyboard navigation
- Related Components: Data Table (with sorting/filtering)
- Patterns: Table Behavior — Row interactivity, action placement, visual state rules
- Patterns: Layouts — Page-level layout patterns
Last updated: August 19, 2026