Tables
Headless-friendly Material Design 3 Expressive table primitives designed for seamless integration with TanStack Table.
Material Design 3 Expressive Table components provide structured data presentation with tactile state layers, fluid spring-animated sort indicators, responsive density scaling, and semantic color tokens.
Because modern data tables require complex state management (sorting, filtering, pagination, column reordering, virtualization), these primitives are intentionally headless-first — they handle 100% of MD3 Expressive styling and accessibility while leaving state architecture to TanStack Table.
Features
- Native TanStack Table v9 Architecture: Headless-first integration engineered specifically for modern
tableFeaturesanduseTableAPIs. No state lock-in. - Optimal Tree-Shaking: Register only the exact features,
sortFn_*, andfilterFn_*functions required without bundling unused comparators into production assets. - Reactive Store State: Direct, real-time access to reactive table state (
table.state) powered by TanStack Store atoms for zero stale render lag. - Expressive Motion: Fluid spring-physics sort indicator transitions using
FAST_SPATIAL_SPRING. - Semantic MD3 Tokens: Strict use of
bg-m3-*,text-m3-*, and elevation tokens without arbitrary hex colors. - Interactive State Layers: Built-in 6% on-surface hover layers, row selection (
bg-m3-secondary-container), and disabled states. - Adaptive Density: Supports
compact,normal, andspaciousdensity tiers for dense telemetry or touch tablets. - Sticky & Pinning Ready: High-performance sticky headers with frosted-glass backdrop blur and column pinning utilities.
- Full WAI-ARIA: Semantic
<table>,role="table", and automatedaria-sort="ascending|descending|none".
Installation
Import table primitives from the @bug-on/m3-expressive/tables subpath:
# Install core package
pnpm add @bug-on/m3-expressive
# Install TanStack Table for headless data logic
pnpm add @tanstack/react-table
import {
MD3Table,
MD3TableContainer,
MD3TableHead,
MD3TableBody,
MD3TableFooter,
MD3TableRow,
MD3TableHeaderCell,
MD3TableCell,
} from "@bug-on/m3-expressive/tables";
Anatomy
- Table Container (
MD3TableContainer): Surface card with rounded MD3 corners (rounded-3xl) and horizontal scroll viewport. - Table Root (
MD3Table): Semantic<table>element propagating density context. - Table Head (
MD3TableHead): Elevated container for header rows, with optionalstickybackdrop blur. - Header Cell (
MD3TableHeaderCell): Column headers with integrated spring sort indicators, click handlers, andaria-sort. - Table Body (
MD3TableBody): Divided row stack with MD3 outline-variant borders. - Table Row (
MD3TableRow): Data row supportingselected,hoverable,striped, anddisabledstates. - Table Cell (
MD3TableCell): Data cell withnumerictabular alignment and column pinning. - Table Footer (
MD3TableFooter): Bottom area for summaries or pagination toolbars.
Examples
Basic Sorting Table
Interactive sorting on column headers with smooth spring animations.
Row Selection
Multi-row selection using @bug-on/m3-expressive/forms Checkbox components and active row highlighting.
Pagination & Page Size
Server or client-side paginated ledger with @bug-on/m3-expressive/buttons controls and row count summaries.
Sticky Header Telemetry
Pinned header with frosted glass backdrop blur and scrollable data stream.
Full Featured Inventory Table
Kitchen-sink implementation featuring global search filtering, sorting, selection, density toggle, and pagination.
Integration with TanStack Table v9
Modern web tables often require advanced headless data coordination — sorting, filtering, row selection, pagination, and virtualization. @bug-on/m3-expressive/tables primitives are architected to pair seamlessly with TanStack Table v9.
The 3-Step v9 Architecture
In TanStack Table v9, tables are defined with explicit feature composition rather than monolithic option bags:
- Compose Features (
tableFeatures): Explicitly declare which functional plugins your table utilizes (sorting, pagination, selection, filtering). - Define Typed Columns: Strongly type columns with
ColumnDef<typeof features, TData>[]. - Instantiate (
useTable): Mount the table instance using store-backed reactivity.
import * as React from "react";
import {
useTable,
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFn_text,
sortFn_basic,
flexRender,
type ColumnDef,
type SortingState,
} from "@tanstack/react-table";
import {
MD3Table,
MD3TableContainer,
MD3TableHead,
MD3TableBody,
MD3TableRow,
MD3TableHeaderCell,
MD3TableCell,
} from "@bug-on/m3-expressive/tables";
interface User {
id: string;
name: string;
email: string;
}
// 1. Compose the exact features your table needs
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: {
basic: sortFn_basic,
text: sortFn_text,
},
});
// 2. Type columns against the composed features
const columns: ColumnDef<typeof features, User>[] = [
{ accessorKey: "id", header: "ID" },
{ accessorKey: "name", header: "Full Name" },
{ accessorKey: "email", header: "Email Address" },
];
export function UserTable({ data }: { data: User[] }) {
const [sorting, setSorting] = React.useState<SortingState>([]);
// 3. Mount table with store reactivity
const table = useTable({
features,
data,
columns,
state: { sorting },
onSortingChange: setSorting,
});
return (
<MD3TableContainer variant="outlined">
<MD3Table>
<MD3TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<MD3TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<MD3TableHeaderCell
key={header.id}
sortDirection={header.column.getIsSorted()}
onSort={header.column.getToggleSortingHandler()}
>
{header.isPlaceholder
? null
: flexRender(
header.column.columnDef.header,
header.getContext()
)}
</MD3TableHeaderCell>
))}
</MD3TableRow>
))}
</MD3TableHead>
<MD3TableBody>
{table.getRowModel().rows.map((row) => (
<MD3TableRow key={row.id} selected={row.getIsSelected()} hoverable>
{row.getAllCells().map((cell) => (
<MD3TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</MD3TableCell>
))}
</MD3TableRow>
))}
</MD3TableBody>
</MD3Table>
</MD3TableContainer>
);
}
Tree-Shaking: Registering Specific sortFn_* and filterFn_*
In TanStack Table v9, importing the monolithic sortFns or filterFns registry object is deprecated because it bundles every comparator into your production bundle regardless of whether you use it.
To achieve minimum bundle footprint and maximum tree-shaking:
- Import only the functions you need (e.g.
sortFn_basic,sortFn_text,sortFn_alphanumeric,filterFn_includesString). - Register them into the
sortFnsorfilterFnsslot intableFeatures:
import {
tableFeatures,
rowSortingFeature,
columnFilteringFeature,
globalFilteringFeature,
createSortedRowModel,
createFilteredRowModel,
sortFn_basic,
sortFn_text,
sortFn_alphanumeric,
filterFn_includesString,
} from "@tanstack/react-table";
const features = tableFeatures({
rowSortingFeature,
columnFilteringFeature,
globalFilteringFeature,
sortedRowModel: createSortedRowModel(),
filteredRowModel: createFilteredRowModel(),
sortFns: {
basic: sortFn_basic,
text: sortFn_text,
alphanumeric: sortFn_alphanumeric,
},
filterFns: {
includesString: filterFn_includesString,
},
});
Row Cell Iteration: getAllCells() vs getVisibleCells()
In v9 core row model, all cell data is available via row.getAllCells():
- Use
row.getAllCells(): For standard tables where all columns remain visible. This avoids requiring the extracolumnVisibilityFeaturebundle. - Use
row.getVisibleCells(): When usingcolumnVisibilityFeatureto dynamically show/hide table columns.
Modular Feature Recipes
1. Sorting Recipe
import {
tableFeatures,
rowSortingFeature,
createSortedRowModel,
sortFn_basic,
sortFn_text,
} from "@tanstack/react-table";
const features = tableFeatures({
rowSortingFeature,
sortedRowModel: createSortedRowModel(),
sortFns: { basic: sortFn_basic, text: sortFn_text },
});
// In header cell:
<MD3TableHeaderCell
sortDirection={header.column.getIsSorted()}
onSort={header.column.getToggleSortingHandler()}
>
{flexRender(header.column.columnDef.header, header.getContext())}
</MD3TableHeaderCell>
2. Pagination Recipe
import {
tableFeatures,
rowPaginationFeature,
createPaginatedRowModel,
} from "@tanstack/react-table";
const features = tableFeatures({
rowPaginationFeature,
paginatedRowModel: createPaginatedRowModel(),
});
// Modern v9 navigation controls:
<IconButton onClick={() => table.firstPage()} disabled={!table.getCanPreviousPage()}>
<Icon name="first_page" />
</IconButton>
<IconButton onClick={() => table.previousPage()} disabled={!table.getCanPreviousPage()}>
<Icon name="chevron_left" />
</IconButton>
<IconButton onClick={() => table.nextPage()} disabled={!table.getCanNextPage()}>
<Icon name="chevron_right" />
</IconButton>
<IconButton onClick={() => table.lastPage()} disabled={!table.getCanNextPage()}>
<Icon name="last_page" />
</IconButton>
// Reactive pagination values (no stale render snapshot):
const { pageIndex, pageSize } = table.state.pagination;
const pageCount = table.getPageCount();
3. Row Selection Recipe
import { tableFeatures, rowSelectionFeature } from "@tanstack/react-table";
import { Checkbox } from "@bug-on/m3-expressive/forms";
const features = tableFeatures({ rowSelectionFeature });
// Selection column definition:
const selectCol = {
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllRowsSelected()}
indeterminate={table.getIsSomeRowsSelected()}
onCheckedChange={(val) => table.toggleAllRowsSelected(!!val)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
disabled={!row.getCanSelect()}
onCheckedChange={(val) => row.toggleSelected(!!val)}
aria-label="Select row"
/>
),
};
Migrating from v8 to v9 Cheat Sheet
| Capability | Legacy / v8 API (@tanstack/react-table/legacy) | Native TanStack Table v9 API |
|---|---|---|
| Hook | useReactTable(...) / useLegacyTable(...) | useTable({ features, columns, data, ... }) |
| Feature Configuration | Implicit monolithic options | Explicit tableFeatures({ rowSortingFeature, ... }) |
| Row Models | getCoreRowModel(), getSortedRowModel() | Core model is built-in; sortedRowModel: createSortedRowModel() |
| Sort Functions | Pre-bundled monolithic registry | Tree-shakeable sortFns: { basic: sortFn_basic, text: sortFn_text } |
| Filter Functions | Pre-bundled monolithic registry | Tree-shakeable filterFns: { includesString: filterFn_includesString } |
| Cell Rendering | row.getVisibleCells() | row.getAllCells() (or getVisibleCells() with columnVisibilityFeature) |
| State Snapshot | table.getState().pagination | table.state.pagination (reactive store state snapshot) |
| Pagination Traversal | table.setPageIndex(0) | table.firstPage(), table.previousPage(), table.nextPage(), table.lastPage() |
API Reference
MD3TableContainer
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "flat" | "outlined" | "elevated" | "outlined" | Outer card styling and elevation shadow. |
className | string | — | Additional CSS utility classes. |
MD3Table
| Prop | Type | Default | Description |
|---|---|---|---|
density | "compact" | "normal" | "spacious" | "normal" | Global vertical and horizontal padding scale. |
className | string | — | Additional CSS utility classes. |
MD3TableHead
| Prop | Type | Default | Description |
|---|---|---|---|
sticky | boolean | false | Fixes the header to the top of the container during scroll with backdrop blur. |
MD3TableRow
| Prop | Type | Default | Description |
|---|---|---|---|
selected | boolean | false | Applies MD3 secondary-container tint and aria-selected="true". |
hoverable | boolean | true | Enables subtle 6% on-surface hover state layer. |
striped | boolean | false | Enables alternating zebra striped row backgrounds. |
disabled | boolean | false | Reduces opacity to 38% and disables pointer events. |
MD3TableHeaderCell
| Prop | Type | Default | Description |
|---|---|---|---|
sortDirection | "asc" | "desc" | false | false | Controls sort arrow orientation and sets aria-sort. |
onSort | (event: MouseEvent) => void | — | Invoked when the cell is clicked to toggle sorting. |
numeric | boolean | false | Aligns header text to the right. |
pinned | "left" | "right" | false | false | Fixes column to horizontal edge during wide scroll. |
pinnedOffset | number | string | — | Horizontal offset for multi-column pinning. |
density | "compact" | "normal" | "spacious" | Parent density | Override cell density locally. |
MD3TableCell
| Prop | Type | Default | Description |
|---|---|---|---|
numeric | boolean | false | Right-aligns content and applies tabular-nums monospace numbers. |
pinned | "left" | "right" | false | false | Fixes column to horizontal edge during wide scroll. |
pinnedOffset | number | string | — | Horizontal offset for multi-column pinning. |
density | "compact" | "normal" | "spacious" | Parent density | Override cell density locally. |
MD3TableCaption
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | — | Additional CSS utility classes. |
Headless Hook (useMD3TableStyling)
For developers building headless virtualized tables or custom HTML table elements without mounting React wrappers:
import { useMD3TableStyling } from "@bug-on/m3-expressive/tables";
function CustomVirtualTable() {
const { getTableClass, getRowClass, getCellClass } = useMD3TableStyling();
return (
<table className={getTableClass("normal")}>
<tr className={getRowClass({ selected: true })}>
<td className={getCellClass({ numeric: true })}>1,234.56</td>
</tr>
</table>
);
}
Design Tokens
| Token | CSS Variable / Utility | Purpose |
|---|---|---|
| Surface Low | bg-m3-surface-container-low | Outlined & elevated table card body |
| Header Surface | bg-m3-surface-container | Table header background layer |
| Selection Layer | bg-m3-secondary-container/60 | Selected row tint |
| State Layer | hover:bg-m3-on-surface/[0.06] | Interactive row hover feedback |
| Divider Outlines | border-m3-outline-variant/30 | Subtle row separators |
| Sort Spring | FAST_SPATIAL_SPRING (Framer) | Spring physics for sort arrow morphing |
Best Practices
Do
- ✅ Use
row.getAllCells()for iteration across rows unless usingcolumnVisibilityFeature(getVisibleCells()). - ✅ Register individual
sortFn_*andfilterFn_*functions intableFeaturesto optimize tree-shaking and minimize production bundle size. - ✅ Access reactive store state directly from
table.state(table.state.pagination,table.state.sorting) for zero stale render lag. - ✅ Pass
numericto bothMD3TableHeaderCellandMD3TableCellfor financial or quantitative columns to align decimal points and applytabular-nums. - ✅ Wrap overflowing tables with
MD3TableContainerto ensure safe horizontal scroll on mobile viewports. - ✅ Pair with
@bug-on/m3-expressive/formsCheckboxand@bug-on/m3-expressive/buttonsIconButtonfor accessible bulk actions.
Don't
- ❌ Don't import deprecated monolithic
sortFnsorfilterFnsregistries; import specificsortFn_*andfilterFn_*comparators to preserve tree-shaking. - ❌ Don't use legacy
useReactTableoruseLegacyTablein modern React 19 apps; useuseTableandtableFeatures. - ❌ Don't hardcode custom hex colors into row backgrounds; use
selected,striped, or semantic MD3 tokens. - ❌ Don't place dense text buttons in compact table rows; use
xssizeIconButtonor icon chips instead.