Experience System Table
Three ways to build tables in the design system — a prop-driven component, composable styled primitives, and a headless TanStack core. Choose the level of control you need.
| ID | Name | Role | |
|---|---|---|---|
| 1001 | Maya Adams | Planner | maya.adams@by.com |
| 1002 | Noah Patel | Dispatcher | noah.patel@by.com |
| 1003 | Lina Chen | Analyst | lina.chen@by.com |
| 1004 | Diego Cruz | Manager | diego.cruz@by.com |
The design system ships three table surfaces built on the same design tokens. The prop-driven component and the styled primitives share the same styling variants (density, borders, striping); the headless core ships only minimal default styles and leaves the rest to class hooks. Start high-level and drop down a layer when you need more control.
Choosing an approach
| Approach | Import from | Registry item | Use when |
|---|---|---|---|
Prop-driven ExperienceSystemTable | @by/experience-system | @by-es/experience-system-table | You want a ready table: pass data + columns and get density, borders, sorting, column menus, pinning, and sizing built in. |
| Styled primitives | @by/experience-system | @by-es/experience-system-table-primitives | You need a bespoke layout but want our table styling. Compose the parts yourself and apply the shared variants. |
Headless core Table | @by/experience-system-table-core | — | You want TanStack + semantic table markup with minimal default styling and class hooks for full control. |
All three render real <table> semantics. Spacing utilities such as px-scaled-* assume ThemeProvider from @by/experience-system in the app shell.
Prop-driven table
ExperienceSystemTable from @by/experience-system is the batteries-included option. You supply data and TanStack columns; density, borders, and zebra striping are props, and optional sorting, column menus, pinning, sizing, and range selection are available without rebuilding the table.
Installation
Run the shadcn CLI with your package manager. It vendors the recipe into components/ui/experience-system-table.tsx and installs the runtime dependencies (@by/experience-system) automatically:
pnpm dlx shadcn@latest add @by-es/experience-system-tableIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system. Configure the Registry in your app before adding recipes.
Usage
Keep data / columns stable (module scope or memoized) and tune presentation through props.
'use client';
import { ExperienceSystemTable, type ColumnDef } from '@by/experience-system';
type ShipmentRow = { id: string; status: string; carrier: string; eta: string };
const columns: ColumnDef<ShipmentRow>[] = [
{ accessorKey: 'id', header: 'Load ID' },
{ accessorKey: 'status', header: 'Status' },
{ accessorKey: 'carrier', header: 'Carrier' },
{ accessorKey: 'eta', header: 'ETA' },
];
const data: ShipmentRow[] = [
{ id: 'LD-5001', status: 'On Time', carrier: 'North Fleet', eta: '12:40' },
{ id: 'LD-5002', status: 'At Risk', carrier: 'Atlas Line', eta: '13:05' },
];
export function ShipmentsTable() {
return <ExperienceSystemTable data={data} columns={columns} density="standard" bordered />;
}ExperienceSystemTable drives TanStack internally, so render it inside a Client Component (or a dynamic import) when using the Next.js App Router.
When to use
Reach for the prop-driven table first. It is the fastest path to a consistent, accessible table and covers the common needs (density, borders, striping, sorting, column menus, pinning, resizing) through props instead of markup.
States
The prop-driven table renders loading, empty, and error states for you — consumers should not rebuild these. Set the state prop for async states; the empty state is derived automatically when data is empty. Precedence is loading > error > empty > data.
| State | How to trigger | What renders |
|---|---|---|
| Loading | state="loading" | Column-aware skeleton rows (count via loadingRowCount, default 5) that preserve column widths. Sets aria-busy and announces politely. |
| Error | state="error" | A default message with an optional retry button (onRetry). Replace the headline with errorState. Rendered as an assertive role="alert". |
| Empty | data is empty (no state) | A default illustration and "No results found." Replace the whole message with emptyState (rendered verbatim). |
| Data | rows present, no state | Normal rows. |
<ExperienceSystemTable
data={rows}
columns={columns}
state={isLoading ? 'loading' : error ? 'error' : undefined}
onRetry={refetch}
loadingRowCount={8}
emptyState="No shipments match your filters."
/>For hand-composed tables, the experience-system-table-primitives registry recipe wires the same loading/empty/error states behind a state prop — copy it and adapt the states however you need.
Sorting
Sorting UI is opt-in and needs no new table prop: the sort affordance renders for a column only when the column is sortable and you explicitly enable sorting — either table-wide with options={{ enableSorting: true }} or per column with enableSorting: true on the column definition. A column definition with enableSorting: false always wins. Without an explicit opt-in the table renders exactly as before.
The golden path is the useTableSorting hook from @by/experience-system-table-core — spread its tableOptions into options (it contains enableSorting: true, so the affordance appears automatically):
'use client';
import { ExperienceSystemTable } from '@by/experience-system';
import { useTableSorting } from '@by/experience-system-table-core';
export function ShipmentsTable() {
const { sorting, tableOptions } = useTableSorting({
initialSorting: [{ id: 'id', desc: false }],
});
return <ExperienceSystemTable data={data} columns={columns} options={tableOptions} />;
}Sortable headers become buttons: click (or Enter / Space) cycles ascending → descending → none, and shift-click adds a secondary sort. Once more than one column is sorted, every sorted column shows its order index ("1", "2", …), exposed to assistive tech as the button's accessible description; removing a column from the sort re-numbers the rest automatically. V3 accumulated sorts on plain clicks (no Shift) — restore that behavior per table by overriding TanStack's multi-sort gate: options={{ ...tableOptions, isMultiSortEvent: () => true }}. aria-sort on the header cell reflects the current state whenever sorting is enabled or wired — and is omitted entirely on tables without a sorting affordance, so headers never announce "sortable" without an operable control. The hook pins sortDescFirst: false so numeric and date columns also start ascending; set sortDescFirst: true on a column definition to opt that column into descending-first.
Offer "Clear sort" through the existing column menu — it is a plain menu item, disabled until the column is sorted:
<ExperienceSystemTable
data={data}
columns={columns}
options={tableOptions}
showColumnMenus
getColumnMenuItems={(header) => [
{
label: 'Clear sort',
disabled: header.column.getIsSorted() === false,
onSelect: () => header.column.clearSorting(),
},
]}
/>Prefer disabled over hidden for state-dependent items like this: the menu trigger unmounts when every item is hidden, and with the browser's auto table layout a header gaining or losing the trigger re-distributes all column widths — the table visibly jumps. A disabled item keeps the menu mounted and the layout stable. For fully jump-proof columns regardless of content, give columns explicit widths (TanStack size on the column defs with the table's column-layout styles).
For server-side sorting, pass manualSorting: true and own the data order: the table stops sorting rows itself and only reports the requested order through onSortingChange. If the rows do not change after a sort click, the consumer forgot to re-fetch — this is the contract, not a bug.
const [sorting, setSorting] = useState<SortingState>([]);
const { tableOptions } = useTableSorting({
sorting,
onSortingChange: setSorting,
manualSorting: true,
});
const { data } = useQuery({ queryKey: ['shipments', sorting], queryFn: fetchShipments });
return <ExperienceSystemTable data={data ?? []} columns={columns} options={tableOptions} />;When combining tableOptions with other controlled state, merge the state key instead of spreading twice:
options={{ ...tableOptions, state: { ...tableOptions.state, columnOrder }, onColumnOrderChange }}For hand-composed tables, render ExperienceSystemTableSortButton from @by/experience-system around the header label — it wires column.getToggleSortingHandler() (tri-state, shift multi-sort, keyboard) and shows the direction icon and multi-sort order index. Render it only for columns where column.getCanSort() is true, and avoid nesting it inside custom header renderers that already contain interactive elements.
The header itself has no hover surface (V3 parity): the icon slot is always reserved so nothing shifts, the inactive icon fades in on hover or keyboard focus, and hovering the icon (not the whole header) shows a state-aware tooltip — Sort by {column} ascending/descending when inactive, Sorted by {column} ascending/descending when active. The tooltip text comes from the column's string header; override it per instance with the part's label prop (change the name only) or tooltip prop (replace the content entirely — the localization hook; compute your own from column.getNextSortingOrder()), or pass tooltip={null} to render no tooltip.
Styled primitives
When you need a custom layout but want to keep our spacing, borders, and tokens, compose the primitives yourself and apply experienceSystemTableVariants on the root for our density and borders. The @by-es/experience-system-table-primitives registry recipe is a copy-and-own starting point — it wires sortable headers (via useTableSorting + ExperienceSystemTableSortButton) and already handles loading (skeleton), empty, and error states out of the box (composed inline from Empty + Skeleton), so you get the same behavior as the prop-driven table, fully yours to edit.
Installation
Run the shadcn CLI with your package manager. It vendors the recipe into components/ui/experience-system-table-primitives.tsx and installs the runtime dependencies (@by/experience-system, …) automatically:
pnpm dlx shadcn@latest add @by-es/experience-system-table-primitivesIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system. Configure the Registry in your app before adding recipes.
Composition
ExperienceSystemTableRoot (apply experienceSystemTableVariants for spacing/borders)
├── ExperienceSystemTableCaption (optional)
├── ExperienceSystemTableHeader
│ └── ExperienceSystemTableRow
│ └── ExperienceSystemTableHead (scope="col")
├── ExperienceSystemTableBody
│ └── ExperienceSystemTableRow
│ └── ExperienceSystemTableCell
└── ExperienceSystemTableFooter (optional)
└── ExperienceSystemTableRow
└── ExperienceSystemTableHead (scope="row") / ExperienceSystemTableCellUsage
import {
ExperienceSystemTableBody,
ExperienceSystemTableCell,
ExperienceSystemTableHead,
ExperienceSystemTableHeader,
ExperienceSystemTableRoot,
ExperienceSystemTableRow,
experienceSystemTableVariants,
} from '@by/experience-system';
const rows = [
{ id: 'LD-5001', carrier: 'North Fleet' },
{ id: 'LD-5002', carrier: 'Atlas Line' },
];
export function ShipmentsTable() {
return (
<ExperienceSystemTableRoot
className={experienceSystemTableVariants({ density: 'standard', bordered: true })}
>
<ExperienceSystemTableHeader>
<ExperienceSystemTableRow>
<ExperienceSystemTableHead scope="col">Load ID</ExperienceSystemTableHead>
<ExperienceSystemTableHead scope="col">Carrier</ExperienceSystemTableHead>
</ExperienceSystemTableRow>
</ExperienceSystemTableHeader>
<ExperienceSystemTableBody>
{rows.map((row) => (
<ExperienceSystemTableRow key={row.id}>
<ExperienceSystemTableCell className="font-medium">{row.id}</ExperienceSystemTableCell>
<ExperienceSystemTableCell>{row.carrier}</ExperienceSystemTableCell>
</ExperienceSystemTableRow>
))}
</ExperienceSystemTableBody>
</ExperienceSystemTableRoot>
);
}The variants use [&_td] / [&_th] descendant selectors, so apply them on the root to reach every cell. The primitives are otherwise unstyled for spacing — you own the rest of the markup.
When to use
Use the primitives when the prop-driven table cannot express your layout (custom grouping, bespoke cells, non-standard structure) but you still want the design system's table look. For full control without our styling, drop to the headless core below.
Headless core
Table from @by/experience-system-table-core renders a semantic <table> with thead and tbody. You supply data and TanStack columns; density, alignment, and borders are entirely class-driven (className, headerClassName, bodyClassName, rowClassName, cellClassName). The root applies text-base tabular-nums by default so numeric columns align cleanly unless you override it.
Installation
The component is published from @by/experience-system-table-core. Add the package with your package manager:
pnpm add @by/experience-system-table-coreIn this monorepo, depend on the workspace package (for example via workspace:* or your catalog) so imports resolve to packages/experience-system-table-core. Theme tokens and Tailwind utilities such as px-scaled-* assume ThemeProvider from @by/experience-system in the app shell.
Composition
Use the following composition to render a Table:
Table (native table)
├── thead
│ └── tr
│ └── th (one per header; scope col / colgroup)
└── tbody
└── tr (one per row)
└── td (one per visible cell)Table owns the table shell; you define ColumnDef values (often with createColumnHelper) and pass data. Advanced behavior (sorting, filtering, pagination) is optional via the options prop and TanStack row models. See TanStack Table.
Usage
Keep data / columns stable in a small wrapper, and pass layout classes from the parent so designers can tune spacing without touching column definitions.
import { createColumnHelper, Table, type TableProps } from '@by/experience-system-table-core';
type Row = { id: number; name: string };
const columnHelper = createColumnHelper<Row>();
const columns = [
columnHelper.accessor('id', { header: 'ID', cell: (info) => info.getValue() }),
columnHelper.accessor('name', { header: 'Name', cell: (info) => info.getValue() }),
];
const data: Row[] = [
{ id: 1, name: 'Ada' },
{ id: 2, name: 'Lin' },
];
type TableViewProps = Omit<TableProps<Row>, 'data' | 'columns' | 'options'>;
export function UserTable(props: TableViewProps) {
return <Table data={data} columns={columns} {...props} />;
}
// Caller
<UserTable
className="w-[760px] border-collapse"
headerClassName="[&_tr_th]:px-scaled-3 [&_tr_th]:py-scaled-2"
bodyClassName="divide-y divide-neutral-alpha-5"
cellClassName="px-scaled-3 py-scaled-2 text-start"
/>;Table is a client component ('use client' in the package). Use it inside a Client Component or a dynamic import when using the Next.js App Router.
When not to use
For non-tabular layouts, use Grid. For loading placeholders, use Skeleton. For very large datasets, add virtualization around rows (for example @tanstack/react-virtual)—not built into Table today.
Examples
Overview
Four-column sample data with border-collapse, row dividers on the body, px-scaled-3 / py-scaled-2 on th via headerClassName, and the same padding on cells. This mirrors TableOverview plus the Table.stories.tsx default args.
| ID | Name | Role | |
|---|---|---|---|
| 1001 | Maya Adams | Planner | maya.adams@by.com |
| 1002 | Noah Patel | Dispatcher | noah.patel@by.com |
| 1003 | Lina Chen | Analyst | lina.chen@by.com |
| 1004 | Diego Cruz | Manager | diego.cruz@by.com |
Styled rows
Two-column shipment list inside a rounded border: zebra rowClassName, column-specific alignment with nth-child selectors on the table, and compact px-scaled-1 padding. Same markup as TableStyled.
| Load ID | Status |
|---|---|
| LD-4191 | On Time |
| LD-4192 | At Risk |
| LD-4193 | Delayed |
| LD-4194 | On Time |
API Reference
Table is implemented in this repo on top of TanStack Table. It forwards native HTML table attributes except where TableProps reserves data for the row array. The package re-exports createColumnHelper, flexRender, common row models, and related types from @tanstack/react-table—see TanStack’s docs for those.
Table
| Prop | Type | Default |
|---|---|---|
data | TData[] | (required) |
columns | ColumnDef<TData, any>[] | (required) |
options | Partial of TanStack TableOptions excluding data, columns, and getCoreRowModel | undefined |
className | string | undefined |
headerClassName | string | undefined |
bodyClassName | string | undefined |
rowClassName | string | ((row: TData, index: number) => string) | undefined |
cellClassName | string | undefined |
The root table merges text-base tabular-nums with your className. thead merges border-b border-neutral-6 with headerClassName; tbody merges divide-y divide-neutral-alpha-5 with bodyClassName. Each th includes font-medium text-left in addition to your header/cell class strategy.
options is merged into the internal useReactTable call with getCoreRowModel always set. Use it for sorting, filtering, pagination, and other table options.
useTableSorting
useTableSorting is the first of the table feature hooks: small hooks from @by/experience-system-table-core that own one feature's state and TanStack wiring — never markup or styling. Every feature hook follows the same contract, so once you know one you know them all: controlled (<state> + on<State>Change) or uncontrolled (initial<State>) usage, change callbacks that receive plain resolved values (never updater functions), manual* options when your server owns the data, and a tableOptions fragment you spread into useTable (or ExperienceSystemTable's options).
| Option | Type | Default |
|---|---|---|
sorting | SortingState | undefined (uncontrolled) |
initialSorting | SortingState | [] |
onSortingChange | (sorting: SortingState) => void | undefined |
enableMultiSort | boolean | true |
enableSortingRemoval | boolean | true |
manualSorting | boolean | false |
Returns { sorting, setSorting, tableOptions }. tableOptions contains state.sorting, onSortingChange, enableSorting: true, the three options above, and sortDescFirst: false (uniform asc → desc → none cycling; override per column definition). onSortingChange always receives the resolved next array, never an updater function — the same shape TanStack stores (Array<{ id: string; desc: boolean }>).
import { useTable, useTableSorting } from '@by/experience-system-table-core';
const { sorting, setSorting, tableOptions } = useTableSorting();
const table = useTable({ data, columns, options: tableOptions });Accessibility
Output is a semantic <table> with <thead> and <tbody>. Header cells use scope="col" (or colgroup when sub-headers exist). Add a caption, visible title, or aria-label when the purpose is not obvious. For interactive headers (sort, filter), supply aria-sort, tabIndex, and keyboard behavior in your column renderers. See MDN: Table accessibility and WAI-ARIA table pattern.
The design system test suite verifies table surfaces against axe-core, and table stories run the Storybook accessibility addon.
State semantics
- Loading sets
aria-busy="true"on the table and announces via a polite live region; skeleton rows arearia-hiddenso assistive tech is not read placeholder content. - Error renders inside
role="alert"(assertive), so the failure is announced when it appears. - Empty and Error message cells span all columns with the correct
colSpan.
Keyboard interactions
Tables expose their controls through the Tab sequence rather than a grid focus model. Spreadsheet-style (Excel) arrow-key cell navigation is intentionally out of scope — build it in cell content only when an application genuinely needs a data-grid.
| Key | Description |
|---|---|
Tab / Shift+Tab | Moves focus through focusable elements inside cells and toolbars in DOM order (sort buttons, links, retry, menus). |
Enter / Space | Activates the focused control. On a sortable header button this cycles the tri-state sort: ascending → descending → none. |
Shift + click | On a sortable header, adds the column as a secondary sort instead of replacing the current sort. |
Escape | Closes an open column menu / popover and returns focus to its trigger. |
| Arrow keys | Reserved for composite widgets (column menu, resize handle). Not a cell-to-cell grid navigation on the table itself. |
Source in the repo: prop-driven packages/experience-system/src/components/ExperienceSystemTable/ExperienceSystemTable.tsx; primitives packages/experience-system/src/components/ExperienceSystemTable/ExperienceSystemTablePrimitives.tsx; headless core packages/experience-system-table-core/src/components/Table.tsx.