Light

Table Behavior

Interaction rules and visual standards for tables across TimelyCare products.

Overview

These rules govern how tables behave — how rows interact, where actions live, and what visual states communicate. They apply to every table in every product. When a table design deviates from these patterns, it needs a documented reason.

When to Apply These Rules

  • Any page that renders a <Table> or data table component
  • Any feature that includes row-level actions, drill-downs, or bulk operations
  • Any design review that touches an existing table

Row Interactivity

If a row has a real detail page, make the row itself clickable.

The row is the navigation affordance. Do not add a redundant "View Details" action to the kebab menu when the row already navigates. Duplicating this creates confusion about which affordance to use.

  • Do: <TableRow className="cursor-pointer" onClick={() => navigate(row.id)}>
  • Don't: clickable row + "View Details" in the kebab

Rows without a detail page stay non-clickable, but still render a hover state.

Not every row needs to go somewhere. Session logs, audit records, and read-only summaries often have no meaningful detail page. These rows should not be made clickable just to follow a pattern — but they must still respond to hover (hover:bg-accent) to communicate that the row is an interactive unit with actions available.

Hover State

All rows — clickable and non-clickable, striped and non-striped — use the same hover token:

TokenValue (light)Value (dark)
bg-accenttc-navy/100 (light mode)tc-navy/800 (dark mode)

This uniformity means there is no special casing in product code. The hover is built into the TableRow component.


Row-Level Actions

Default to a kebab/ellipsis menu (MoreVertical icon) for all row-level actions.

Use the kebab even when there is only one action. The consistency of placement matters more than saving a single click. A table where some rows have inline buttons and others have a kebab is harder to scan than one where the action affordance is always in the same place.

<TableCell onClick={(e) => e.stopPropagation()}>
  <DropdownMenu>
    <DropdownMenuTrigger asChild>
      <Button variant="ghost" className="h-8 w-8 p-0">
        <MoreVertical className="size-4" />
        <span className="sr-only">Row actions</span>
      </Button>
    </DropdownMenuTrigger>
    <DropdownMenuContent align="end">
      <DropdownMenuItem>Edit</DropdownMenuItem>
      <DropdownMenuSeparator />
      <DropdownMenuItem className="text-destructive">Delete</DropdownMenuItem>
    </DropdownMenuContent>
  </DropdownMenu>
</TableCell>

Note: onClick={(e) => e.stopPropagation()} prevents the kebab cell from triggering row-level navigation on clickable rows.

Actions in the kebab must be completable in-context.

"In-context" means the action can be completed on the current page or in a modal that opens over it. If an action sends the user to another page or a multi-step workflow, it belongs on the detail page — not in a row-level kebab.

  • ✓ Edit (opens a modal or inline form)
  • ✓ Archive / Delete (confirmation dialog)
  • ✓ Copy link / Export (immediate, no navigation)
  • ✗ "View full case" (navigates away — the row click handles this)
  • ✗ "Start intake workflow" (multi-step — belongs on the detail page)

Action Hierarchy

Not all actions belong at the row level. Before adding a kebab action, ask: does this need to happen per-row, or would it be better as a table-level or page-level operation?

Table-Level: Bulk Actions

When users frequently need to act on multiple rows at once, push the action to the table header. Multi-select checkboxes plus a header action button is the preferred pattern.

// Toolbar above the table
<div className="flex items-center justify-between">
  <p className="text-sm text-muted-foreground">{selected.size} of {total} selected</p>
  <Button size="sm" variant="outline" disabled={selected.size === 0}>
    Archive Selected
  </Button>
</div>

This avoids repeating the same control on every row and makes bulk operations explicit and reversible.

Row-Level: Lightweight In-Context Actions

Keep actions at the row level only when they are:

  • Lightweight — no navigation, no multi-step workflow
  • Status-style — toggling a state, copying a value, exporting a single record
  • Useful in-context — the user benefits from acting without leaving the list

If an action fails any of these criteria, it belongs on the detail page.

Summary

Action typeWhere it lives
Drill-down / detail viewRow click
In-context edit, archive, deleteRow kebab
Bulk operations (high-frequency)Table toolbar
Multi-step workflowsDetail page
Complex data entryDetail page

Visual States

Zebra Striping

Apply alternating row backgrounds using <TableBody striped>. Use for dense, read-heavy tables where the eye needs help tracking across rows.

TokenValue (light)Value (dark)
bg-tc-gray-50 / dark:bg-tc-gray-800 (odd rows)#FAFAFA (tc-gray/50)#262727 (tc-gray/800)
<TableBody striped>
  {rows.map((row) => (
    <TableRow key={row.id}>
      {/* cells */}
    </TableRow>
  ))}
</TableBody>

Do not combine striped with row-level bg-* class overrides — the stripe will be overridden.

State Reference

StateTokenNotes
Default (non-striped row)bg-input-bg from table container
Default (striped odd row)bg-tc-gray-50 / dark:bg-tc-gray-800Applied via TableBody striped
Hover (all rows)bg-accentUniform — no special casing by stripe type
Selectedbg-accentSame color as hover — selected rows do not gain additional hover behavior

Consistency

Avoid one-off table designs.

Every table in a TimelyCare product should map back to a pattern documented here. When a design requires something not covered — a custom action affordance, a novel row interaction — that's a signal to document the new pattern before shipping it, not to ship first and document later.

Default to the kebab, always.

When in doubt about where to put a row-level action, use the kebab. Do not invent inline buttons, icon buttons in cells, or hover-revealed controls unless there is a specific, documented reason. Consistency of placement matters more than saving a click.


Accessibility

  • Clickable rows must be keyboard-accessible. Add tabIndex={0} to <TableRow> and handle onKeyDown for Enter and Space to trigger the same action as click.
  • Focus rings are built into TableRow via focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50. Do not suppress them with outline-none on the row.
  • Non-clickable rows with hover are purely cosmetic — no tabIndex or keyboard handling is required.
  • Kebab trigger buttons must have an accessible label: <span className="sr-only">Row actions</span> inside the trigger.
  • Destructive actions in the kebab should use className="text-destructive" and ideally be separated from non-destructive actions with a <DropdownMenuSeparator />.
  • Sticky checkbox column — on tables with multi-select, apply sticky left-0 z-10 plus an explicit background (bg-input-bg, or the row's selected-state color) to both the <TableHead> and each <TableCell> in the checkbox column. This keeps the selection state visible when the table scrolls horizontally, ensuring the checkbox visual indicator is never lost off-screen.

See Also


Last updated: May 1, 2026