Skip to main content

Mantle DataTable Action Column Migration Guide

This document describes how to migrate existing DataTable usages to take advantage of the new DataTable.ActionHeader component, which keeps the pinned action column visually aligned across the header and every body row when the table scrolls horizontally.

TL;DR: If your data table has an action column whose cells use DataTable.ActionCell, change that column's header from DataTable.Header (or <Table.Header />, or an empty cell) to DataTable.ActionHeader. That's it.

Codemod

From a mantle checkout:

pnpm -w run migrate 0002 <path>

In a project that installs @ngrok/mantle:

npx --yes --package=@ast-grep/cli@0.51.0 ast-grep scan --rule node_modules/@ngrok/mantle/dist/migrations/0002-data-table-action-header.yml --update-all <path>

The rules apply the whole find/replace recipe. In a column whose cell renders DataTable.ActionCell, they rewrite DataTable.Header, Table.Header, and header: () => null to DataTable.ActionHeader. A header that returns undefined or a fragment stays manual. The rules read a part through its import from @ngrok/mantle/data-table and @ngrok/mantle/table, under its own name or an alias. A part that reaches the file through a local re-export or a destructured namespace stays manual. A function that binds DataTable or Table to a parameter or a local shadows the import, so the rules leave that function alone. A table that a cell renders keeps its own headers, and only its own action column changes. Pass --dry-run to print the rewrites first, and run your formatter after the rewrite.


Why this change

Previously, a typical action column was defined like this:

columnHelper.display({	id: "actions",	header: () => <DataTable.Header />, // plain, non-sticky header	cell: () => <DataTable.ActionCell>{/* action buttons */}</DataTable.ActionCell>,});

The body's DataTable.ActionCell was position: sticky and pinned to the right of the scroll container, but the header cell was a regular <th> that scrolled away with the rest of the row. When the table overflowed horizontally this produced two visible problems:

  1. The sticky body cells stayed pinned at the right while the header's action column scrolled off, so the pinned column did not line up between the header and body.
  2. There was no consistent scroll-fade treatment between the header row and the body rows: the fade indicator only appeared on body cells.

DataTable.ActionHeader is a sticky <th> that mirrors DataTable.ActionCell, so the action column stays pinned and aligned across the header and every body row, with a matching left-side fade that indicates content is scrolling underneath.


What to search for (old patterns)

Search your codebase for any data table column definitions that use DataTable.ActionCell in the cell function. For each such column, inspect the header function. It is almost always one of these:

// OLD: empty DataTable.HeadercolumnHelper.display({	id: "actions",	header: () => <DataTable.Header />,	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,}); // OLD: no header at all (just returns null / undefined)columnHelper.display({	id: "actions",	header: () => null,	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,}); // OLD: raw Table.HeadercolumnHelper.display({	id: "actions",	header: () => <Table.Header />,	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,}); // OLD: DataTable.Header with a labelcolumnHelper.display({	id: "actions",	header: () => <DataTable.Header>Actions</DataTable.Header>,	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,});

Heuristics an agent can use to find these:

  • Any columnHelper.display({ ... }) (or columnHelper.accessor(...)) whose cell returns a <DataTable.ActionCell>.
  • Any file that imports DataTable from @ngrok/mantle/data-table and also uses DataTable.ActionCell.
  • Any column with id: "actions" (common convention but not required).

Replacement pattern (new)

Change the header to DataTable.ActionHeader. It takes all the same props as Table.Header (including children), so you can keep labels, class names, and custom content:

// NEW: empty sticky header (most common case)columnHelper.display({	id: "actions",	header: () => <DataTable.ActionHeader />,	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,}); // NEW: sticky header with a labelcolumnHelper.display({	id: "actions",	header: () => <DataTable.ActionHeader>Actions</DataTable.ActionHeader>,	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,}); // NEW: sticky header with custom content (sr-only label, icon, etc.)columnHelper.display({	id: "actions",	header: () => (		<DataTable.ActionHeader>			<span className="sr-only">Actions</span>		</DataTable.ActionHeader>	),	cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,});

DataTable.ActionHeader applies the sticky positioning, the background, and the left-side fade indicator itself. It needs no other changes from you.


Find/replace recipe

For most codebases a simple mechanical replacement works. Be conservative: only apply the substitution inside a column definition that also uses DataTable.ActionCell in its cell function.

  1. Search for DataTable.ActionCell to find all action-column definitions.
  2. In each match, locate the corresponding header: property in the same columnHelper.display({ ... }) (or equivalent) object.
  3. Replace the header JSX according to the table below:
BeforeAfter
header: () => <DataTable.Header />header: () => <DataTable.ActionHeader />
header: () => <Table.Header />header: () => <DataTable.ActionHeader />
header: () => nullheader: () => <DataTable.ActionHeader />
header: () => <DataTable.Header>X</DataTable.Header>header: () => <DataTable.ActionHeader>X</DataTable.ActionHeader>
header: () => <Table.Header>X</Table.Header>header: () => <DataTable.ActionHeader>X</DataTable.ActionHeader>

If the existing header passes a className or other props to <DataTable.Header>/<Table.Header>, forward them unchanged to <DataTable.ActionHeader>; the component accepts all props from Table.Header.


Imports

No new import is required: DataTable.ActionHeader is exposed on the existing DataTable namespace object. If a file already imports DataTable from @ngrok/mantle/data-table, the migration is complete after the JSX swap:

import { DataTable } from "@ngrok/mantle/data-table";

Example: complete before/after

Before:

import { IconButton } from "@ngrok/mantle/button";import { DataTable, createColumnHelper, tableFeatures } from "@ngrok/mantle/data-table";import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree"; type Payment = { id: string; amount: number; status: string }; const features = tableFeatures({}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	columnHelper.accessor("id", {		id: "id",		header: () => <DataTable.Header>ID</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.display({		id: "actions",		header: () => <DataTable.Header />,		cell: () => (			<DataTable.ActionCell>				<IconButton					appearance="ghost"					intent="neutral"					type="button"					size="sm"					label="Open actions"					icon={<DotsThreeIcon weight="bold" />}				/>			</DataTable.ActionCell>		),	}),]);

After:

import { IconButton } from "@ngrok/mantle/button";import { DataTable, createColumnHelper, tableFeatures } from "@ngrok/mantle/data-table";import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree"; type Payment = { id: string; amount: number; status: string }; const features = tableFeatures({}); const columnHelper = createColumnHelper<typeof features, Payment>(); const columns = columnHelper.columns([	columnHelper.accessor("id", {		id: "id",		header: () => <DataTable.Header>ID</DataTable.Header>,		cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,	}),	columnHelper.display({		id: "actions",		header: () => <DataTable.ActionHeader />, // <-- only change		cell: () => (			<DataTable.ActionCell>				<IconButton					appearance="ghost"					intent="neutral"					type="button"					size="sm"					label="Open actions"					icon={<DotsThreeIcon weight="bold" />}				/>			</DataTable.ActionCell>		),	}),]);

Non-breaking context

This is a purely additive change to the DataTable API:

  • DataTable.Header still works as before for non-action columns.
  • DataTable.ActionCell behavior is unchanged from the consumer's perspective: it is still a sticky <td> positioned at the right of the row. Internally it now renders a left-side indicator span (1px divider + soft shadow gradient) and uses bg-inherit for an opaque background that tracks the row's hover state.
  • DataTable.ActionHeader is empty-state aware: when the table body has no rows, it renders as a plain <th> (no sticky positioning, no indicator, no right-side fade suppression) so the empty state looks natural.
  • Tables without an action column need no changes.
  • Tables with an action column that keep using the old DataTable.Header for the header will continue to render correctly; they won't get the aligned sticky header or the matching fade indicator on the header row.

Migrating every data table that has a DataTable.ActionCell is recommended for visual consistency, but can be done incrementally.


Alongside DataTable.ActionHeader, Table.Root (and therefore every DataTable.Root) received the following internal behavior changes. Consumers typically do not need to do anything; they are listed here for completeness:

  • Scroll fade mask is now applied inside the border. Table.Root now renders an outer wrapper (border, rounded corners, background) and an inner scroll container (mask + horizontal scrolling). The border and rounded corners stay crisp at every scroll position; the fade only affects the scrolling table content.
  • Bidirectional scroll fade via scroll-fade-x. Both left and right edges fade based on scroll position. When the table contains a sticky right column (DataTable.ActionCell / DataTable.ActionHeader), the right-side fade is suppressed so the pinned column stays fully opaque; the pinned column renders its own left-side indicator instead.
  • Y-scroll is disabled on the scroll container. The inner scroll container uses overflow-x: auto; overflow-y: clip so the table only scrolls horizontally. Tall tables grow the container; page-level scrolling handles vertical overflow as before.
  • Overscroll bounce is disabled on both axes via overscroll-behavior: none.
  • Sticky cells are opaque. DataTable.ActionCell and DataTable.ActionHeader use background-color: inherit so they pick up the row's current background (including hover state) and cover scrolling content behind them.
  • Scroll detection uses useLayoutEffect and requestAnimationFrame coalescing. The horizontal overflow observer now corrects state before the browser paints (avoiding flash-of-incorrect-state) and batches rapid-fire scroll, resize, and mutation events into a single layout read per frame.

No consumer code change is required for any of the above.