Mantle Tabs Separator Migration Guide
@ngrok/mantle's Tabs.List draws no border of its own. The hideBorder prop and the data-hide-border attribute are gone, and so is the side border of a vertical classic list. The new Tabs.Separator part draws the hairline instead: a sibling of the list that spans the root's full width and neither scrolls nor fades with the triggers. This guide moves a codebase from the default border onto the part.
TL;DR: Add <Tabs.Separator /> directly after every classic Tabs.List that relied on the default border. Delete hideBorder from every Tabs.List; a list that had it needs no separator. On a Tabs.Root that holds the separator as a direct child, replace a gap-* class with [--tabs-gap:…].
Codemod
From a mantle checkout:
In a project that installs @ngrok/mantle:
The rules apply steps 2 and 3. They add <Tabs.Separator /> after a classic Tabs.List without hideBorder, delete hideBorder, and turn hideBorder={condition} into {!condition && <Tabs.Separator />}. The nearest Tabs.Root decides whether a list is classic: a root with no appearance, or a literal one other than "pill". A root with a dynamic appearance={value} stays manual, because a separator shows on pill tabs, so its lists keep hideBorder for the type checker to flag. A list that already precedes a Tabs.Separator, with or without props or a condition, gets no second one and loses its flag. A list that a rule cannot give a sibling keeps a false or dynamic hideBorder for the type checker to flag: a list inside {show && …}, a self-closing list, and a list whose Tabs.Root is in another file. The rules read a part through its import from @ngrok/mantle/tabs, 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 Tabs to a parameter or a local shadows the import, so the rules leave that function alone. Steps 4 to 6 stay manual, because a data-hide-border selector, a gap-* class, and a border-* class are strings, not JSX syntax. Run the codemod once: a migrated list that had hideBorder reads the same as an unmigrated one, so a second run adds a separator to it. Pass --dry-run to print the rewrites first, and run your formatter after the rewrite.
You are an agent migrating a codebase. Do this:
- Find every
Tabs.List(the tablist of@ngrok/mantle'sTabscompound component). Search forTabs.List, and for a destructured or renamed import of the same part (const { List } = Tabs;, orimport { Tabs as MantleTabs }). - For each
Tabs.ListwithouthideBorder, check the root'sappearance. When the root is classic (the default), add<Tabs.Separator />on the line after</Tabs.List>, at the same indentation. When the root isappearance="pill", add nothing. A pill list never drew a border. - For each
Tabs.ListwithhideBorder, delete the prop and add nothing. When the value is dynamic, keep the condition and negate it:hideBorder={condition}becomes{!condition && <Tabs.Separator />}on the line after</Tabs.List>. - Find a
data-hide-borderselector in CSS, in a[data-hide-border]Tailwind variant, or in a test. Replace it with[data-slot~="tabs-separator"], or with the absence of that element. - Find a
Tabs.Rootwith agap-*class in itsclassName. When the root holds aTabs.Separatoras a direct child, replace the class with[--tabs-gap:<value>], sogap-2becomes[--tabs-gap:--spacing(2)]andgap-0becomes[--tabs-gap:0px]. When the list and the separator sit inside a wrapper of your own, leave the class alone. - Delete a
border-b,border-r, orborder-separatorclass from aTabs.Listthat a consumer added to draw its own border. The separator replaces it. - After editing, run the project's formatter, linter, type checker, and tests.
Why a sibling part? A horizontal list is a scroll container, and it carries negative margins and padding so the focus ring has room. A border painted on the list itself had to live inside that padding, so it stopped short of the root's edges, and the scroll-fade mask faded it with the triggers. A sibling is outside the scroll container: it spans the root's full width, it never scrolls, and the mask never reaches it.
Before → after
A classic list that relied on the default border
A list that hid the border
A root that overrode the gap
A list inside a page header
The separator is not the root's direct child here, so it takes no offset and sits where the wrapper lays it out.
Edge cases & gotchas
- A vertical classic list. It drew a side border, which is gone the same way.
Tabs.Separatorreads the root'sorientationand draws a vertical hairline beside the list. - A pill list. It never drew a border, and
hideBorderwas a no-op there. Delete the prop and add nothing. - A
gap-*class on a root that holds the separator as a direct child. The class changes the gap alone, and the separator keeps an offset of1rem, so it overlaps the triggers or floats below them. Set--tabs-gapinstead. - A separator inside a wrapper. It takes no offset. When the wrapper has its own
gap, give the wrapper no gap, or pass a negative margin of that size in the separator'sclassName. - A
[data-hide-border]selector. Nothing stamps the attribute any more, so the selector matches nothing and your style stops applying. Select[data-slot~="tabs-separator"]instead. - A consumer's own
border-bonTabs.List. It still paints inside the list's focus-ring padding, short of the root's edges, and now beside a separator, so the page shows two lines. Delete it. - Semantic separators. The separator is
role="none"by default. Passsemanticforrole="separator"when a screen reader needs the boundary. - Tests that count the root's children by position. The separator is one more child between the list and the content. Select by role instead:
getByRole("tablist"),getByRole("tabpanel").
How to verify
- Type check. A remaining
hideBorderfails onTabs.List. - Search for
data-hide-border. Zero hits. - Load a page with a classic list. The hairline runs the root's full width, flush under the triggers, with the active tab's decoration above it.
- Load a page with a horizontal list that overflows. Scroll the list. The hairline stays put and stays solid to both edges while the triggers fade.
- Load a page with a vertical list. The hairline runs beside the list, the full height of the root.
- Load a page whose root had a
gap-*class. The hairline touches the list, and the content keeps the space you set. - Run the project's formatter, linter, type checker, and tests.