Table

A responsive table component.

Spec · from metadata

When to use

  • Displaying structured tabular data (rows and columns)
  • Showing lists of records with multiple attributes
  • Read-only data presentation with optional striped rows

When not to use

  • For sortable/filterable/paginated tables — use DataTable pattern with @tanstack/react-table instead
  • For key-value pairs — use a description list or Card layout instead
  • For layout purposes — use CSS Grid or Flexbox instead

Variants

PropValuesDefaultDescription
stripedtruefalsefalsePass `striped` prop to TableBody for zebra striping — odd rows use bg-tc-gray-50 (dark:bg-tc-gray-800). Hover and selected states take precedence on striped rows.
variantcardflushcardContainer chrome. `card` (default) = a self-contained padded, bordered card (matches Helix Card) — for a single table that owns its page surface, with header/controls/footer around it. `flush` = no chrome or padding, for a table inside a Card that provides the container (used when several tables share a page and each is grouped with its own header/controls/footer).
stickyHeadertruefalsefalsePins TableHeader to the top of the table's own scroll panel. Requires `maxHeight` — sticky needs a bounded scroll container, and without one the prop is a no-op that logs a dev-only warning. When set, the header takes `bg-card` so rows do not show through it, and a `card`-variant container drops its vertical padding (a scrollbox's padding gutters are inside the scrollport, so rows would otherwise be visible above the pinned header).
maxHeightany CSS length, e.g. "24rem"noneCaps the height of the container Table owns, making it a scroll panel — the bound `stickyHeader` needs. Applied as an inline style, not a Tailwind class, because the value is caller-chosen and must not become an arbitrary utility. Useful on its own for a long table that should not stretch the page.

Anti-patterns

Avoid<Table><TableRow><TableCell>...</TableCell></TableRow></Table>
Prefer<Table><TableHeader><TableRow><TableHead>...</TableHead></TableRow></TableHeader><TableBody><TableRow><TableCell>...</TableCell></TableRow></TableBody></Table>

Always use TableHeader/TableBody sections for proper semantics and accessibility

Avoid<div className="overflow-x-auto"><Table>...</Table></div>
Prefer<Table>...</Table>

Table already renders inside an overflow-x-auto container — wrapping again adds unnecessary nesting

Avoid<CardContent><Table>...</Table></CardContent>
Prefer<CardContent><Table variant="flush">...</Table></CardContent>

A card-variant table inside a Card applies the card treatment twice — the container's p-6 stacks with CardContent's px-6 and its border/rounded-xl/shadow-sm draws a second surface. Nothing in the type system catches it, so Table warns about it at runtime in development.

Avoid<div className="border rounded-lg overflow-hidden"><Table>...</Table></div>
Prefer<Table>...</Table>

The card variant already supplies rounded-xl border bg-card shadow-sm p-6, so a bordered, rounded wrapper draws a second border at a different radius. Drop the wrapper (the table is the surface), or keep it and use variant="flush" so exactly one element owns the border and radius — never just match the wrapper's radius to the table's, which hides the double border instead of removing it. Table warns in development when its immediate parent has both a border width and a border radius.

Avoid<Table stickyHeader>...</Table>
Prefer<Table stickyHeader maxHeight="24rem">...</Table>

Sticky needs a bounded scroll container. Without maxHeight the container never scrolls, so the header has nothing to pin against and stickyHeader does nothing (dev-only warning).

Accessibility

  • Screen readerUse TableCaption to provide a description of the table for screen readers
  • ContrastHeader cells use muted-foreground; ensure sufficient contrast with background

Token bindings

TokenCategoryUsage
bg-cardcolorCard-variant container background, and the sticky header's fill (with `stickyHeader`) so rows do not show through it
text-muted-foregroundcolorHeader cell and caption text color
bg-accentcolorRow hover and selected-state (data-[state=selected]) background
bg-tc-gray-50colorStriped odd-row background (dark: bg-tc-gray-800)
Recent changes
  • new

    Table can pin its header row

    2026-08-19

  • fixed

    Table warns when a card-variant table is nested in a Card

    2026-08-19

  • breaking

    Table card/flush variants; default is now a padded card

    2026-07-24

Import

import {
  Table,
  TableBody,
  TableCaption,
  TableCell,
  TableFooter,
  TableHead,
  TableHeader,
  TableRow,
} from "@timelycare/helix-ui"

Structure

PartTailwind
Table (container)w-full overflow-x-auto + variant chrome (see Container variants); overflow-y-auto + an inline max-height with stickyHeader
Tablew-full caption-bottom text-sm
TableHeader— (no fill); sticky top-0 z-10 bg-card when the enclosing Table has stickyHeader
TableRowborder-b border-border transition-colors [tbody_&]:hover:bg-accent focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50
TableHeadh-10 px-2 text-left align-middle text-sm font-semibold text-muted-foreground
TableCellh-[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
TableFooterborder-t font-medium
TableCaptionpt-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 Helix Card: 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 surrounding Card provides 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>
PropTypeNotes
stickyHeaderbooleanPins 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.
maxHeightstring (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

SizeCell HeightUse For
defaulth-[52px]Standard data tables
mdh-[72px]Tables with more content
lgh-[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 showUse
The row under the cursorNothing — hover is bg-accent, built into TableRow, and already takes precedence over the stripe
A selected rowdata-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 summaryTableFooter — 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:

  • striped coexists 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-row bg-* overrides.
  • Use striping only on TableBody rows; header and footer rows are unaffected.

Cell Variants

VariantContentUse For
DefaultText onlySimple data
Bold TextTitle + descriptionPrimary column, identifiers
BadgeBadge componentStatus indicators
AvatarAvatar + textUser columns
SwitchSwitch toggleSettings tables
ButtonAction buttonRow actions
DropdownDropdownMenu trigger with MoreVertical iconMultiple actions
ProgressProgress barCompletion status
ImageAspectRatio imageMedia tables
InputInput fieldEditable cells
Toggle GroupToggle buttonsOptions selection

States

StateImplementation
DefaultBase 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
Selectedbg-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). Add max-w-* to control where a column wraps; add whitespace-nowrap per-cell only if you need a specific column to stay on one line.
  • Single-line rows center on align-middle; add align-top per-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

StatusBadge Variant
Active / SuccessoutlineSage
Scheduled / Upcoming / CompletedoutlineSage
Pending / In ProgressoutlineNavy
Needs attention / Action requiredoutlineOrange
Error / FailedoutlineBerry
CancelledoutlineBerry
No Show / Missed / SkippedoutlineNeutral
Inactive / DisabledoutlineNeutral

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> with scope="col" for proper structure
  • Screen reader: header/cell associations handled automatically with proper markup
  • Clickable rows: add tabIndex={0} to <TableRow> and handle onKeyDown for Enter/Space — the focus ring is built in via focus-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, not bg-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

ProblemSolution
Caption at topCaption is always bottom by default
Missing hoverEnsure [tbody_&]:hover:bg-accent on TableRow, and that the rows sit inside TableBody
No keyboard focus ringAdd tabIndex={0} to the row — the ring is already in TableRow base styles and activates automatically
Sticky column bleedsSet explicit bg-card (or the row's active bg) on sticky <TableHead> and <TableCell> — transparent sticky cells let scrolling content show through
Alignment issuesUse align-middle on cells
Last cell borderRemove with border-b-0 if needed
Wide tablesAlready 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 nothingmaxHeight 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 headerThe fill is bg-card, applied for you with stickyHeader. If you overrode the header's background, use an opaque token

See Also


Last updated: August 19, 2026