Experience System

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.

IDNameRoleEmail
1001Maya AdamsPlannermaya.adams@by.com
1002Noah PatelDispatchernoah.patel@by.com
1003Lina ChenAnalystlina.chen@by.com
1004Diego CruzManagerdiego.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

ApproachImport fromRegistry itemUse when
Prop-driven ExperienceSystemTable@by/experience-system@by-es/experience-system-tableYou 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-primitivesYou 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-coreYou 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-table

In 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.

StateHow to triggerWhat renders
Loadingstate="loading"Column-aware skeleton rows (count via loadingRowCount, default 5) that preserve column widths. Sets aria-busy and announces politely.
Errorstate="error"A default message with an optional retry button (onRetry). Replace the headline with errorState. Rendered as an assertive role="alert".
Emptydata is empty (no state)A default illustration and "No results found." Replace the whole message with emptyState (rendered verbatim).
Datarows present, no stateNormal 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-primitives

In 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") / ExperienceSystemTableCell

Usage

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-core

In 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.

IDNameRoleEmail
1001Maya AdamsPlannermaya.adams@by.com
1002Noah PatelDispatchernoah.patel@by.com
1003Lina ChenAnalystlina.chen@by.com
1004Diego CruzManagerdiego.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 IDStatus
LD-4191On Time
LD-4192At Risk
LD-4193Delayed
LD-4194On 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

PropTypeDefault
dataTData[](required)
columnsColumnDef<TData, any>[](required)
optionsPartial of TanStack TableOptions excluding data, columns, and getCoreRowModelundefined
classNamestringundefined
headerClassNamestringundefined
bodyClassNamestringundefined
rowClassNamestring | ((row: TData, index: number) => string)undefined
cellClassNamestringundefined

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).

OptionTypeDefault
sortingSortingStateundefined (uncontrolled)
initialSortingSortingState[]
onSortingChange(sorting: SortingState) => voidundefined
enableMultiSortbooleantrue
enableSortingRemovalbooleantrue
manualSortingbooleanfalse

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 are aria-hidden so 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.

KeyDescription
Tab / Shift+TabMoves focus through focusable elements inside cells and toolbars in DOM order (sort buttons, links, retry, menus).
Enter / SpaceActivates the focused control. On a sortable header button this cycles the tri-state sort: ascending → descending → none.
Shift + clickOn a sortable header, adds the column as a secondary sort instead of replacing the current sort.
EscapeCloses an open column menu / popover and returns focus to its trigger.
Arrow keysReserved 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.