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:
| Token | Value (light) | Value (dark) |
|---|---|---|
bg-accent | tc-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 type | Where it lives |
|---|---|
| Drill-down / detail view | Row click |
| In-context edit, archive, delete | Row kebab |
| Bulk operations (high-frequency) | Table toolbar |
| Multi-step workflows | Detail page |
| Complex data entry | Detail 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.
| Token | Value (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
| State | Token | Notes |
|---|---|---|
| Default (non-striped row) | — | bg-input-bg from table container |
| Default (striped odd row) | bg-tc-gray-50 / dark:bg-tc-gray-800 | Applied via TableBody striped |
| Hover (all rows) | bg-accent | Uniform — no special casing by stripe type |
| Selected | bg-accent | Same 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 handleonKeyDownfor Enter and Space to trigger the same action as click. - Focus rings are built into
TableRowviafocus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50. Do not suppress them withoutline-noneon the row. - Non-clickable rows with hover are purely cosmetic — no
tabIndexor 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-10plus 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
- Related Components: Table, Data Table
- Related Patterns: Filtering — Filter controls for table toolbars
Last updated: May 1, 2026