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.
Previously, a typical action column was defined like this:
1columnHelper.display({2 id: "actions",3 header: () => <DataTable.Header />, // plain, non-sticky header4 cell: () => <DataTable.ActionCell>{/* action buttons */}</DataTable.ActionCell>,5});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:
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.
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:
1// OLD: empty DataTable.Header2columnHelper.display({3 id: "actions",4 header: () => <DataTable.Header />,5 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,6});7 8// OLD: no header at all (just returns null / undefined)9columnHelper.display({10 id: "actions",11 header: () => null,12 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,13});14 15// OLD: raw Table.Header16columnHelper.display({17 id: "actions",18 header: () => <Table.Header />,19 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,20});21 22// OLD: DataTable.Header with a label23columnHelper.display({24 id: "actions",25 header: () => <DataTable.Header>Actions</DataTable.Header>,26 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,27});Heuristics an agent can use to find these:
columnHelper.display({ ... }) (or columnHelper.accessor(...)) whose cell returns a <DataTable.ActionCell>.DataTable from @ngrok/mantle/data-table and also uses DataTable.ActionCell.id: "actions" (common convention but not required).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:
1// NEW: empty sticky header (most common case)2columnHelper.display({3 id: "actions",4 header: () => <DataTable.ActionHeader />,5 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,6});7 8// NEW: sticky header with a label9columnHelper.display({10 id: "actions",11 header: () => <DataTable.ActionHeader>Actions</DataTable.ActionHeader>,12 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,13});14 15// NEW: sticky header with custom content (sr-only label, icon, etc.)16columnHelper.display({17 id: "actions",18 header: () => (19 <DataTable.ActionHeader>20 <span className="sr-only">Actions</span>21 </DataTable.ActionHeader>22 ),23 cell: () => <DataTable.ActionCell>{/* ... */}</DataTable.ActionCell>,24});No other changes are required. The styling (sticky positioning, background, and left-side fade indicator) is applied by DataTable.ActionHeader itself.
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.
DataTable.ActionCell to find all action-column definitions.header: property in the same columnHelper.display({ ... }) (or equivalent) object.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.
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:
1import { DataTable } from "@ngrok/mantle/data-table";Before:
1import { IconButton } from "@ngrok/mantle/button";2import {3 DataTable,4 createColumnHelper,5 getCoreRowModel,6 useReactTable,7} from "@ngrok/mantle/data-table";8import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree";9 10type Payment = { id: string; amount: number; status: string };11 12const columnHelper = createColumnHelper<Payment>();13 14const columns = [15 columnHelper.accessor("id", {16 id: "id",17 header: () => <DataTable.Header>ID</DataTable.Header>,18 cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,19 }),20 columnHelper.display({21 id: "actions",22 header: () => <DataTable.Header />,23 cell: () => (24 <DataTable.ActionCell>25 <IconButton26 appearance="ghost"27 type="button"28 size="sm"29 label="Open actions"30 icon={<DotsThreeIcon weight="bold" />}31 />32 </DataTable.ActionCell>33 ),34 }),35];After:
1import { IconButton } from "@ngrok/mantle/button";2import {3 DataTable,4 createColumnHelper,5 getCoreRowModel,6 useReactTable,7} from "@ngrok/mantle/data-table";8import { DotsThreeIcon } from "@phosphor-icons/react/DotsThree";9 10type Payment = { id: string; amount: number; status: string };11 12const columnHelper = createColumnHelper<Payment>();13 14const columns = [15 columnHelper.accessor("id", {16 id: "id",17 header: () => <DataTable.Header>ID</DataTable.Header>,18 cell: (props) => <DataTable.Cell>{props.getValue()}</DataTable.Cell>,19 }),20 columnHelper.display({21 id: "actions",22 header: () => <DataTable.ActionHeader />, // <-- only change23 cell: () => (24 <DataTable.ActionCell>25 <IconButton26 appearance="ghost"27 type="button"28 size="sm"29 label="Open actions"30 icon={<DotsThreeIcon weight="bold" />}31 />32 </DataTable.ActionCell>33 ),34 }),35];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.DataTable.Header for the header will continue to render correctly — they just 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:
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.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 provides its own left-side indicator instead.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-behavior: none.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.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.