Responsivetable
ImplementedGeneric data table that switches between table, stacked, and card layouts by viewport width, with priority-sorted columns and an EmptyState fallback.
components/organisms/ResponsiveTable/ResponsiveTable.tsxGeneric data table that switches between table, stacked, and card layouts by viewport width — priority-sorted columns, optional render functions, and an EmptyState molecule fallback for empty data.
ResponsiveTable
Organism — Data display family
One column model, three layouts: a real <table> with scope="col" headers from 1024px, a stacked label/value list below that, and <dl> definition-list cards below 768px. responsiveVariant "auto" follows the viewport or pins a layout; columns carry a priority that reorders narrow layouts; empty data renders the compact EmptyState molecule (or your emptyState node in a role="status" wrapper).
responsiveVariant: auto — viewport-driven (card < 768, stacked < 1024, table ≥ 1024)
| Event | Tickets | Gross | Venue |
|---|---|---|---|
| Soirée Jazz | 312 | $12,012.00 | Café Barcode |
| Bal des Boys | 148 | $4,440.00 | Salle Albert-Rousseau |
| Yelle en spectacle | 640 | $35,200.00 | Impériel Bell |
| Nuit du Piano | 203 | $6,090.00 | Palais Montcalm |
Column priorities (event 1 → venue 4) reorder the card/stacked layouts; an empty data array would render the compact EmptyState molecule.
This component supports light and dark mode.
Usage
ResponsiveTable is generic over its row type. Define columns with either an accessor (primitive value) or a render function (arbitrary content), give them priority values so the narrow layouts lead with the primary data, and pass data plus an ariaLabel describing the table. Leave responsiveVariant on auto unless a surface needs a pinned layout — the preview above pins each one so you can compare them.
import { ResponsiveTable } from '@epasslive/design-system'
const columns: Column<SalesRow>[] = [
{ id: 'event', header: 'Event', accessor: (r) => r.event, priority: 1 },
{ id: 'tickets', header: 'Tickets', accessor: (r) => r.tickets, priority: 2 },
{ id: 'gross', header: 'Gross', priority: 3, render: (r) => fmtCad(r.gross) },
{ id: 'venue', header: 'Venue', accessor: (r) => r.venue, priority: 4 },
]
<ResponsiveTable
columns={columns}
data={rows}
responsiveVariant="auto"
ariaLabel={t('salesByEvent')}
/>Layout behavior
auto resolves the layout from the viewport width — card below 768px, stacked below 1024px, a real table from 1024px up. The card layout renders each row as a <dl> definition list (label from cardLabelAccessor or the header), the stacked layout pairs a label with each value, and the table layout keeps the declaration order with scope="col" headers. Priority reorders only the narrow layouts. Empty data never renders a blank box: the compact EmptyState molecule shows "No data available." unless you pass your own emptyState node.
Accessibility
Each layout keeps a perceivable structure: real <table> with scope="col" headers, definition lists for cards, paired label/value spans when stacked (WCAG 1.3.1). The region is named by ariaLabel so sibling tables are distinguishable (WCAG 4.1.2), and the empty state is announced politely (role="status"). The component is presentational — no focusable cells, no keyboard traps.
Props
columns*Column<T>[]Column definitions — see the column table below.
data*T[]Row data — one item per row, typed by the generic. An empty array triggers the empty state.
responsiveVariant"auto" | "table" | "stacked" | "card""auto"Layout selector — auto resolves card < 768px, stacked < 1024px, table ≥ 1024px.
cardLabelAccessor(row: T, column: Column<T>) => stringOverrides the card-layout field label (defaults to column.header).
emptyStateReact.ReactNodeCustom empty-state node, rendered inside a role="status" wrapper. Omit for the compact EmptyState molecule.
ariaLabelstringAccessible name for the layout region (or the empty-state wrapper).
Column definition
Column<T> — the single column model shared by all three layouts.
id*stringColumn identifier — also the fallback key when reading the row value by property name.
header*stringHeader label; becomes the field label in card/stacked layouts.
render(row: T, rowIndex: number) => React.ReactNodeCustom cell renderer — omitted cells fall back to accessor(row) or row[col.id].
widthstringCSS width applied to the <th> in the table layout (e.g. "25%").
prioritynumber100Sort key for the narrow layouts — columns re-order ascending by priority in card and stacked mode.
accessor(row: T) => string | numberPrimitive value extractor for the default cell content.