Mantle Button Icon Swap Migration Guide
@ngrok/mantle's Button and IconButton swap the icon and the isLoading spinner with a blur and fade. Both icons stay mounted, so the icon is no longer a direct child of the <button>. It is inside a <span data-slot="icon-swap">, in a <span data-slot="icon-swap-item">. A child selector such as [&>svg]:size-3.5 on the button no longer reaches the icon, and the icon keeps mantle's default size. This guide moves those selectors to a form that reaches the icon.
TL;DR: On a Button with an icon prop, and on every IconButton, replace each [&>svg] variant in className with [&_svg]. Better: delete the variant and put the class on the icon element (icon={<PlusIcon className="size-3.5" />}). Leave [&>svg] on every other part alone.
Codemod
Codemod: manual: the change is a Tailwind variant inside a class string, and a rule cannot tell which [&>svg] targets the button's own icon.
You are an agent migrating a codebase. Do this:
- Find every
ButtonandIconButtonfrom@ngrok/mantle/button. Include an aliased import (import { Button as MantleButton }) and a local wrapper that forwardsclassNameto one of them. - Keep only the ones that render a mantle icon: a
Buttonwith aniconprop, and everyIconButton. AButtonwithouticonthat isisLoadingstill renders the spinner as a direct child, so its[&>svg]selector still works. - In the
classNameof each one, find a[&>svg]variant ([&>svg]:size-3.5,[&>svg]:shrink-0,[&>svg]:text-muted). Also find the spelled-out form[&_>_svg], and a> svgselector in CSS that targets a button. - Replace
[&>svg]with[&_svg]. When the variant only sets the icon's size or color, you can instead delete it and pass the class to the icon element:icon={<PlusIcon className="size-3.5" />}, or<IconButton icon={<TrashIcon className="size-3.5" />} />. - Leave
[&>svg]on every other part alone:Select.Trigger,Tabs.Trigger,DropdownMenu.Item, and plain elements did not change. - Find a test that reads the button's icon as a direct child (
button.querySelector(":scope > svg"),button.firstElementChild). Querysvgas a descendant instead, or query[data-slot="icon-swap-item"][data-state="active"] svgfor the icon that shows. - After editing, run the project's formatter, linter, type checker, and tests.
Why a wrapper? A swap that mounts one icon and unmounts the other is a hard cut: the new icon pops in. To blur and fade between them, both icons must stay mounted in one grid cell while a CSS transition runs. Each icon gets its own wrapper, so the swap owns the wrapper's
transitionand the icon keeps its own (a rotating caret, for example).
Before → after
A Button that sized its icon with a child selector
An IconButton that sized its icon with a child selector
A part that did not change
Edge cases & gotchas
- The hidden spinner. Every
IconButton, and everyButtonwith anicon, now renders the spinner SVG too, hidden witharia-hidden="true"andopacity: 0.[&_svg]sizes both, so the swap keeps one size. A test that counts the SVGs in a button finds one more. - An SVG in the children.
[&_svg]also reaches an SVG that you pass as a child of aButton, not only theicon. When the two need different sizes, put the class on each icon element. asChild. AnIconButtonwithasChildputs the swap inside your child element. The same selector change applies to the child'sclassName.- A loading
Buttonwith noicon. It renders the spinner directly, with no swap. A[&>svg]variant still reaches it, and[&_svg]does too.
How to verify
- Search each
Buttonwith aniconprop and eachIconButtonfor[&>svg]. Zero hits. - Load a page with a button that had a custom icon size. The icon has that size, not mantle's default
size-5. - Set
isLoadingon that button. The spinner has the same size as the icon, and the button does not change size. - Run the project's formatter, linter, type checker, and tests.