Table
Data table with composable subcomponents. Sorting, selection, striped rows, size variants.
Table is a composable data-display primitive. The container plus its subcomponents (TableHeader, TableBody, TableRow, TableHead, TableCell, TableActionsCell, TableSubheader) handle the structural HTML; the consumer composes cells with whatever content the row needs — text, badges, links, action buttons. Typed cells (TableIconCell, TableTextCell, TableAvatarCell, TableImageCell, TableServiceTagCell) package the common icon / identity / logo / classification layouts so consumers stop hand-rolling them — see ADR-027 for which layer owns a new cell.
Use it for
- Tabular records with consistent columns (users, projects, invoices)
- Sortable lists where the user picks a column to order by
- Comparison tables on settings or pricing pages
- Any "rows of structured data" where columns share semantics across rows
For card-like rows where each entry is its own self-contained block, use CardList. For settings panels with action-per-row, use Card preset="control".
Import
import {
Table,
TableHeader,
TableBody,
TableRow,
TableHead,
TableCell,
TableActionsCell,
} from '@brikdesigns/bds';Variants
Sizes
<Table size="default">...</Table> {/* compact, dense data */}
<Table size="comfortable">...</Table> {/* generous padding, 72px cell height */}Striped
Alternating row backgrounds for readability on dense tables.
<Table striped>...</Table>Flush
flush removes the left padding on the first cell and right padding on the last — use when the table aligns to a container's edge with no internal padding.
<Table flush>...</Table>Sortable headers
TableHead accepts sortable and sortDirection. The consumer owns the click handler and sort state.
<TableHead sortable sortDirection="asc" onClick={() => sortBy('name')}>
Name
</TableHead>Selected rows
TableRow accepts a selected boolean for highlight styling.
<TableRow selected>
<TableCell>Active row</TableCell>
</TableRow>Cell-level interactivity
Tables host three classes of click target — no others. This is the
canon locked in portal#837 — Read and edit conventions.
Don't reach for raw <a> or <button> — use the BDS components below.
| Cell class | Affordance | Pattern |
|---|---|---|
| Identifier (Name) | <TextLink size="small"> opening the read sheet for that row (or navigating to the read page when the table has no sheet, e.g. customer_stories, blog_posts) | Click target |
| Foreign-key reference | <TextLink size="small"> opening the read sheet of the referenced entity (Service Line in a Services row, Service in an Offerings row, Company in admin rows) | Click target |
| Status / Public / Featured (read-only) | <Badge> or plain text | Not interactive |
| Actions | <TableActionsCell> with [View][Edit][⋯] icon buttons in size="sm" | Click targets |
Forbidden — whole-row click
Never bind onClick to <TableRow>. It violates table cell semantics,
breaks screen-reader expectations, and conflicts with cell-level
affordances. Move the handler down to the appropriate cell — Name and
FK cells become <TextLink>s; trailing actions live in
<TableActionsCell>.
TableActionsCell
The right-aligned trailing actions column. Owns three things — and
nothing else: alignment (canonical right, optional center for
symmetric tables), shrink-to-content width, and the --gap-sm rhythm
between buttons. Carries aria-label="Actions" automatically.
<TableActionsCell>
<Button variant="primary" size="sm" icon={<EyeIcon />} label="View" onClick={openSheet} />
<Button variant="primary" size="sm" icon={<PenIcon />} label="Edit" onClick={navigateEdit} />
{/* Optional overflow for tertiary actions (Duplicate, Archive, Delete) */}
<Button variant="ghost" size="sm" icon={<EllipsisIcon />} label="More" onClick={openMenu} />
</TableActionsCell>Common icon-button variants:
| Variant | Use |
|---|---|
primary | Primary action (view, edit, open) |
secondary | Neutral (download, copy) |
ghost | Low emphasis (overflow menu) |
destructive | Destructive (delete, remove) — use sparingly in-row; prefer the overflow menu |
Text links
Use <TextLink size="small"> for the Name cell and any FK cell. Never use raw <a> or full-size text links.
{/* Name cell — opens the row's read sheet */}
<TextLink size="small" onClick={() => openSheet(service.id)}>
{service.name}
</TextLink>
{/* FK cell — opens the related entity's read sheet */}
<TextLink size="small" onClick={() => openSheet(service.serviceLineId, 'service-line')}>
{service.serviceLine}
</TextLink>Text link vs button:
- TextLink — navigation that takes you somewhere (open read sheet, navigate to profile)
- Button — actions that change state (Approve, Assign, Archive)
Tooltip indicators
Wrap info icons in a Tooltip so they reveal context on hover/focus. Use cursor: help to signal interactivity.
<Tooltip content="Primary contact email" placement="top">
<span style={{ cursor: 'help' }}>
<Icon icon="ph:info" />
</span>
</Tooltip>Typed cells
Typed cells package the recurring cell layouts. Each renders the <td> itself, composes an existing primitive, and owns only cell-level concerns (alignment, gap, fixed sizing, row-density padding) — reach for these instead of hand-rolling flex wrappers inside a plain <TableCell>. ADR-027 is the rule for which layer owns a new cell: it earns a component when it renders the table element and owns a cell-level concern. Anything else is a primitive inside <TableCell>.
TableIconCell — a label preceded by a fixed 24px icon box, so labels align down the column even when glyph widths differ. The icon is aria-hidden — the label carries the accessible text.
<TableIconCell icon={<Icon icon="ph:palette" />}>Design</TableIconCell>TableTextCell — a two-line primary / secondary stack; the avatar-less counterpart to TableAvatarCell, sharing its type ramp so the two read identically in one table.
<TableTextCell primary={user.name} secondary={user.email} />TableAvatarCell — an Avatar paired with a name and an optional secondary line (email / role). Initials render when src is omitted; pass primary as a <TextLink> when the name should open a read sheet.
<TableAvatarCell
src={user.avatarUrl}
name={user.name}
secondary={user.email}
status="online"
/>TableImageCell — a fixed square (1:1) thumbnail for a logo or product image, via the Image primitive at ratio="1-1". Use fit="contain" for logos (no crop) and fit="cover" for product photos. The cell renders only — the upload / edit interaction is consumer-side (e.g. a file uploader in the edit sheet).
<TableImageCell src={org.logoUrl} alt={`${org.name} logo`} fit="contain" size="sm" />TableServiceTagCell — hosts one or more ServiceTags, wrapping with a consistent gap. Supply variant="icon" for icon-only classification or variant="icon-text" for a labeled tag.
<TableServiceTagCell>
<ServiceTag category="brand" variant="icon" />
<ServiceTag category="marketing" variant="icon-text" serviceName="Custom Standard Web Development and Design" />
</TableServiceTagCell>When not to use
- Don't use Table for self-contained card grids. Cards belong in CardList.
- Don't use Table for settings rows. Use Card
preset="control"— locked layout for badge + title + description + action. - Don't use Table for narrow mobile views. Tables don't gracefully wrap; consider a card-list layout for mobile-first data.
Accessibility
- Renders real
<table>/<thead>/<tbody>/<tr>/<th>/<td>— semantics are platform-native. - Sortable headers carry
aria-sortreflecting the current sort direction. - Selected rows use
aria-selected. - Action buttons in cells are real buttons — keyboard, focus, screen reader announce normally.
API
Table
| Prop | Type | Default |
|---|---|---|
striped | boolean | false |
size | 'default' | 'comfortable' | 'default' |
flush | boolean | false |
children | ReactNode (required) | — |
headerBorder | boolean | false |
headerBorderWeight | TableHeaderBorderWeight | 'md' |
roundedTop | boolean | true |
roundedBottom | boolean | true |
headerBackground | TableHeaderBackground | 'secondary' |
Plus standard <table> HTML attributes.
Subcomponents
| Component | Element | Notable props |
|---|---|---|
TableHeader | <thead> | — |
TableBody | <tbody> | — |
TableRow | <tr> | selected: boolean — never onClick (see callout) |
TableHead | <th> | sortable: boolean, sortDirection: 'asc' | 'desc' |
TableCell | <td> | — |
TableActionsCell | <td> | align: 'right' | 'center' — owns the trailing [View][Edit][⋯] cluster |
TableAvatarCell | <td> | src, name, primary, secondary, size: 'sm' | 'md', status — Avatar + identity |
TableTextCell | <td> | primary (req), secondary — two-line identity stack, no avatar |
TableIconCell | <td> | icon (req), children (req) — fixed-box leading icon + label |
TableImageCell | <td> | src (req), alt (req), size: 'sm' | 'md', fit: 'contain' | 'cover' — square 1:1 thumbnail |
TableServiceTagCell | <td> | children — one or more ServiceTags |
All subcomponents accept their respective HTML attributes.
Usage
import {
Table, TableHeader, TableBody, TableRow, TableHead,
TableCell, TableActionsCell,
} from '@brikdesigns/bds';
import { Badge, Button, TextLink } from '@brikdesigns/bds';
<Table striped size="comfortable">
<TableHeader>
<TableRow>
<TableHead sortable sortDirection="asc">Name</TableHead>
<TableHead>Service line</TableHead>
<TableHead>Status</TableHead>
<TableHead style={{ textAlign: 'right' }}>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell>
<TextLink size="small" onClick={() => openSheet(service.id)}>
{service.name}
</TextLink>
</TableCell>
<TableCell>
<TextLink size="small" onClick={() => openSheet(service.serviceLineId, 'service-line')}>
{service.serviceLine}
</TextLink>
</TableCell>
<TableCell><Badge status="positive" size="sm">Active</Badge></TableCell>
<TableActionsCell>
<Button variant="primary" size="sm" icon={<EyeIcon />} label="View" onClick={() => openSheet(service.id)} />
<Button variant="primary" size="sm" icon={<PenIcon />} label="Edit" onClick={() => navigateEdit(service.id)} />
</TableActionsCell>
</TableRow>
</TableBody>
</Table>Related
- CardList — card grid alternative
- Card
preset="control"— settings-row pattern (replaces the deprecatedCardControl) - Pagination — pair below large tables
- Badge / Tag — common cell content
- Storybook playground