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:
In a project that installs @ngrok/mantle:
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:
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:
- 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.
- 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:
Heuristics an agent can use to find these:
- Any
columnHelper.display({ ... })(orcolumnHelper.accessor(...)) whosecellreturns a<DataTable.ActionCell>. - Any file that imports
DataTablefrom@ngrok/mantle/data-tableand also usesDataTable.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:
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.
- Search for
DataTable.ActionCellto find all action-column definitions. - In each match, locate the corresponding
header:property in the samecolumnHelper.display({ ... })(or equivalent) object. - Replace the header JSX according to the table below:
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:
Example: complete before/after
Before:
After:
Non-breaking context
This is a purely additive change to the DataTable API:
DataTable.Headerstill works as before for non-action columns.DataTable.ActionCellbehavior 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 usesbg-inheritfor an opaque background that tracks the row's hover state.DataTable.ActionHeaderis 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.Headerfor 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.
Related table behavior changes
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.Rootnow 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: clipso 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.ActionCellandDataTable.ActionHeaderusebackground-color: inheritso they pick up the row's current background (including hover state) and cover scrolling content behind them. - Scroll detection uses
useLayoutEffectandrequestAnimationFramecoalescing. 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.