MD3
Expressive
MATERIAL DESIGN 3 EXPRESSIVE

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 tableFeatures and useTable APIs. No state lock-in.
  • Optimal Tree-Shaking: Register only the exact features, sortFn_*, and filterFn_* 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, and spacious density 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 automated aria-sort="ascending|descending|none".

Installation

Import table primitives from the @bug-on/m3-expressive/tables subpath:

bash
# Install core package
pnpm add @bug-on/m3-expressive

# Install TanStack Table for headless data logic
pnpm add @tanstack/react-table
tsx
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 optional sticky backdrop blur.
  • Header Cell (MD3TableHeaderCell): Column headers with integrated spring sort indicators, click handlers, and aria-sort.
  • Table Body (MD3TableBody): Divided row stack with MD3 outline-variant borders.
  • Table Row (MD3TableRow): Data row supporting selected, hoverable, striped, and disabled states.
  • Table Cell (MD3TableCell): Data cell with numeric tabular 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.

Loading demo...

Row Selection

Multi-row selection using @bug-on/m3-expressive/forms Checkbox components and active row highlighting.

Loading demo...

Pagination & Page Size

Server or client-side paginated ledger with @bug-on/m3-expressive/buttons controls and row count summaries.

Loading demo...

Sticky Header Telemetry

Pinned header with frosted glass backdrop blur and scrollable data stream.

Loading demo...

Kitchen-sink implementation featuring global search filtering, sorting, selection, density toggle, and pagination.

Loading demo...

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:

  1. Compose Features (tableFeatures): Explicitly declare which functional plugins your table utilizes (sorting, pagination, selection, filtering).
  2. Define Typed Columns: Strongly type columns with ColumnDef<typeof features, TData>[].
  3. Instantiate (useTable): Mount the table instance using store-backed reactivity.
tsx
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 sortFns or filterFns slot in tableFeatures:
tsx
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 extra columnVisibilityFeature bundle.
  • Use row.getVisibleCells(): When using columnVisibilityFeature to dynamically show/hide table columns.

Modular Feature Recipes

1. Sorting Recipe

tsx
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

tsx
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

tsx
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

CapabilityLegacy / v8 API (@tanstack/react-table/legacy)Native TanStack Table v9 API
HookuseReactTable(...) / useLegacyTable(...)useTable({ features, columns, data, ... })
Feature ConfigurationImplicit monolithic optionsExplicit tableFeatures({ rowSortingFeature, ... })
Row ModelsgetCoreRowModel(), getSortedRowModel()Core model is built-in; sortedRowModel: createSortedRowModel()
Sort FunctionsPre-bundled monolithic registryTree-shakeable sortFns: { basic: sortFn_basic, text: sortFn_text }
Filter FunctionsPre-bundled monolithic registryTree-shakeable filterFns: { includesString: filterFn_includesString }
Cell Renderingrow.getVisibleCells()row.getAllCells() (or getVisibleCells() with columnVisibilityFeature)
State Snapshottable.getState().paginationtable.state.pagination (reactive store state snapshot)
Pagination Traversaltable.setPageIndex(0)table.firstPage(), table.previousPage(), table.nextPage(), table.lastPage()

API Reference

MD3TableContainer

PropTypeDefaultDescription
variant"flat" | "outlined" | "elevated""outlined"Outer card styling and elevation shadow.
classNamestring—Additional CSS utility classes.

MD3Table

PropTypeDefaultDescription
density"compact" | "normal" | "spacious""normal"Global vertical and horizontal padding scale.
classNamestring—Additional CSS utility classes.

MD3TableHead

PropTypeDefaultDescription
stickybooleanfalseFixes the header to the top of the container during scroll with backdrop blur.

MD3TableRow

PropTypeDefaultDescription
selectedbooleanfalseApplies MD3 secondary-container tint and aria-selected="true".
hoverablebooleantrueEnables subtle 6% on-surface hover state layer.
stripedbooleanfalseEnables alternating zebra striped row backgrounds.
disabledbooleanfalseReduces opacity to 38% and disables pointer events.

MD3TableHeaderCell

PropTypeDefaultDescription
sortDirection"asc" | "desc" | falsefalseControls sort arrow orientation and sets aria-sort.
onSort(event: MouseEvent) => void—Invoked when the cell is clicked to toggle sorting.
numericbooleanfalseAligns header text to the right.
pinned"left" | "right" | falsefalseFixes column to horizontal edge during wide scroll.
pinnedOffsetnumber | string—Horizontal offset for multi-column pinning.
density"compact" | "normal" | "spacious"Parent densityOverride cell density locally.

MD3TableCell

PropTypeDefaultDescription
numericbooleanfalseRight-aligns content and applies tabular-nums monospace numbers.
pinned"left" | "right" | falsefalseFixes column to horizontal edge during wide scroll.
pinnedOffsetnumber | string—Horizontal offset for multi-column pinning.
density"compact" | "normal" | "spacious"Parent densityOverride cell density locally.

MD3TableCaption

PropTypeDefaultDescription
classNamestring—Additional CSS utility classes.

Headless Hook (useMD3TableStyling)

For developers building headless virtualized tables or custom HTML table elements without mounting React wrappers:

tsx
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

TokenCSS Variable / UtilityPurpose
Surface Lowbg-m3-surface-container-lowOutlined & elevated table card body
Header Surfacebg-m3-surface-containerTable header background layer
Selection Layerbg-m3-secondary-container/60Selected row tint
State Layerhover:bg-m3-on-surface/[0.06]Interactive row hover feedback
Divider Outlinesborder-m3-outline-variant/30Subtle row separators
Sort SpringFAST_SPATIAL_SPRING (Framer)Spring physics for sort arrow morphing

Best Practices

Do

  • ✅ Use row.getAllCells() for iteration across rows unless using columnVisibilityFeature (getVisibleCells()).
  • ✅ Register individual sortFn_* and filterFn_* functions in tableFeatures to 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 numeric to both MD3TableHeaderCell and MD3TableCell for financial or quantitative columns to align decimal points and apply tabular-nums.
  • ✅ Wrap overflowing tables with MD3TableContainer to ensure safe horizontal scroll on mobile viewports.
  • ✅ Pair with @bug-on/m3-expressive/forms Checkbox and @bug-on/m3-expressive/buttons IconButton for accessible bulk actions.

Don't

  • ❌ Don't import deprecated monolithic sortFns or filterFns registries; import specific sortFn_* and filterFn_* comparators to preserve tree-shaking.
  • ❌ Don't use legacy useReactTable or useLegacyTable in modern React 19 apps; use useTable and tableFeatures.
  • ❌ 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 xs size IconButton or icon chips instead.