Swapping a Component Library Without Stopping the Product
Moving a React app from MUI to shadcn-style primitives sounds like a rewrite. It doesn't have to be. Here's the approach I set up: codemods for the boring parts, a thin layer of your own components, and a migration that ships one screen at a time.
5 min read
Earlier this month we set up a small project with one job: help move a React front end from MUI to shadcn-style components built on Radix primitives and Tailwind.
Component library migrations have a bad reputation. They tend to start as "let's just swap the buttons" and end as a six-month branch nobody wants to merge. They don't have to. But it helps to have a plan before touching any code.
This post is the plan I'd follow, and the reasoning behind it.
Why move at all#
MUI is a good library. It's mature, it's well documented, and you can build a lot with it quickly. So it's worth being honest about why you'd leave.
For me, the reasons come down to ownership:
- You don't own the components. With a packaged library, the component is a black box you configure. When a design needs something slightly different, you fight the theme system or override styles deep in the tree.
- The styling model is its own world. Theme objects, style overrides and a styling engine sit alongside whatever else the app uses. Two styling systems in one app is one too many.
- Design drifts toward the library. When the library makes something easy, that's what gets built. Over time, the product starts to look like the library's defaults instead of your own design.
The shadcn approach flips that. You don't install a component library. You copy well-built components into your own codebase, on top of accessible primitives, styled with utility classes. Then they're yours. You read them, change them and delete the parts you don't need.
That's a better fit for a product that wants its own look and a design system it controls.
The rule: never stop shipping#
The single most important decision is that the product keeps shipping during the migration. No long-lived branch. No freeze.
That means both libraries live in the app at the same time, for a while. It's a bit messy. It's much less messy than a branch that drifts further from main every day.
The way to make that safe is to change things in small, complete slices:
- Pick one screen or one component type.
- Move it fully.
- Ship it.
- Repeat.
Every merge leaves the app working. If something goes wrong, the blast radius is one screen.
Step one: build your own layer first#
Before converting any screens, I'd build the small set of components the app actually uses, in the new style, inside the codebase:
components/ui/
button
input
select
dialog
card
table
badge
tooltip
These are your components now. They sit on accessible primitives, use your tokens for colour and spacing, and expose a simple API you choose.
That API matters. A good trick is to keep prop names close to what the old components used where it makes sense. A variant prop on a button, a disabled prop, the same size names. The closer they are, the more of the migration becomes mechanical.
Step two: let codemods do the boring part#
A large share of any migration is repetitive. Change this import. Rename this prop. Swap this component for that one. People are slow and inconsistent at that kind of work. Code is not.
That's the idea behind the conversion project: codemods, which are scripts that parse each source file into a syntax tree, find specific patterns and rewrite them. The tooling it's set up with is ordinary:
- A JavaScript parser to read files into a syntax tree, so changes are structural, not find-and-replace.
- A test runner with before-and-after fixtures for every transform.
- A formatter so the rewritten code comes out looking like a person wrote it.
A transform, in rough terms:
for each file:
parse into a syntax tree
find imports from the old library
for each known component:
swap the import to components/ui
rename props that changed
drop props that no longer apply
if something can't be mapped safely:
leave it alone and add a comment for a person
print the tree back out, then format
The last rule is the important one. A codemod should do the easy 80% perfectly and refuse to guess at the rest. Anything it can't map with confidence gets left in place, with a note, for a person.
And every transform gets a test. A fixture file goes in, the expected output comes out. When someone finds a case the codemod gets wrong, it becomes a new fixture. That's how a codemod gets trustworthy enough to run across a whole codebase.
Step three: convert by screen, not by component#
There are two ways to slice the work:
- By component. Replace every button in the app, then every input, and so on.
- By screen. Take one screen and convert everything on it.
Component-by-component sounds tidier, but it touches every screen at once and makes review hard. Screen-by-screen gives you complete, testable slices. You can look at one page, before and after, and know whether it's right.
I'd start with a low-traffic screen to shake out problems, then move to the busy ones once the components and codemods have been tested on something real.
Step four: watch the things people don't see#
The visual change is the easy part to check. The risks are in what you can't see:
- Accessibility. Focus handling, keyboard navigation and screen reader labels. Building on accessible primitives helps a lot, but check dialogs, menus and selects by hand.
- Forms. Validation messages, error states and how values are passed around. Forms are where component APIs differ most.
- Bundle size. While both libraries are in the app, the bundle is bigger. That's fine for a while. It's a good reason not to let the migration drag on.
What design gets out of it#
This isn't only an engineering change. It changes how design decisions reach the product.
With a packaged library, a design decision often turns into a theme override, and the override lives far from the component it affects. With your own components, the path is short. Colours, spacing, radius and type are tokens, defined once as CSS variables and used through utility classes. The component file shows exactly how those tokens are applied.
That makes design review concrete. A designer can open the button component and see its variants, sizes and states in one place. When the design changes, there's one file to update, and every screen that uses it follows.
It also makes it easier to say no. A component you own only has the variants you've chosen. That small constraint keeps a product looking like itself.
Step five: actually finish#
The hardest part of any migration is the last 10%. The old library is down to a few odd components on screens nobody visits. It's tempting to leave them.
Don't. Until the old library is fully removed, you're paying for both: two styling systems, two sets of patterns and a bigger bundle. Put the removal on the plan as a real task with a date, not as "eventually".
A simple check helps. Make it a lint rule or a CI step that counts imports from the old library. The number should only go down. When it hits zero, remove the package.
The takeaway#
A component library migration doesn't need a freeze or a giant branch. Build your own small layer of components first, let tested codemods handle the repetitive changes, convert one complete screen at a time and keep shipping throughout. Then finish the job and remove the old library, because owning your components is the whole point.
Related
- Design
A Quiet Brand in Ten Tokens
For the Midwest site I wanted something dark, quiet and credible. The whole visual system fits in ten colour tokens and a short list of rules. Here's why a small palette and a few hard rules beat a big design system for a brand like this.
Design systems · Front-end5 min read
- Engineering
One Table, Four Audiences
The clinical data pipeline I've been building shows records to four very different kinds of user. Instead of four tables, there's one pattern: server-side pagination, state in the URL and a single scoping rule applied on the server. Here's how it fits together.
React · State management · Healthcare · Front-end5 min read
- Engineering
The Key Trail: The Easy Way to Run Form-Heavy CMSs
Let the route's params pick the data, the state path and the form. In a form-heavy CMS or admin tool, a new screen becomes one route plus one field array, with no new endpoint and no new form code.
Forms · State management · Architecture · Front-end3 min read