MUI to Shadcn: 7 Pitfalls We Hit (And the Fix for Each)
We replaced MUI with Radix + Shadcn across a multi-tenant SaaS dashboard over the last quarter. Bundle size dropped. Theming finally made sense. Our design system stopped being two different design systems wearing a trench coat. It also wasn't smooth. Most "migrate to shadcn" posts you read online are clean. They show you the add command, the new component, a screenshot, and call it done. That's not a migration. That's a greenfield. A real migration is six different teams with five different opinions, twelve dialogs in production traffic, and a Tuesday morning where staging breaks because someone's portal isn't rendering inside an iframe. Here are the seven things that bit us, in roughly the order they bit us. If you're staring down the same migration, this is the post I wish someone had written for me. 1. Dialog focus trap behaved differently than MUI's MUI's Dialog aggressively reclaims focus on every render. Radix's Dialog doesn't and that's actually correct, but it surfaced bugs that MUI was hiding for us. We had several dialogs that mounted with a form inside, and the form's first input depended on async data. With MUI, focus eventually landed on the input regardless. With Radix, focus landed on the dialog body and stayed there. // What we ended up doing The fix isn't in the dialog. It's in the form: explicit autoFocus on the first input once data is ready. The migration forced us to be honest about which component owns focus. That's a good thing but plan for it. 2. The Drawer wasn't a drawer MUI ships a Drawer component. Shadcn does too, but the one you actually want for a side panel inside an app shell is Sheet which is just Dialog with side="right" . This sounds trivial. It cost us half a day. We migrated three drawers to Drawer , hit weird animation glitches on close, then realized Drawer is for mobile-first bottom sheets. Swapped to Sheet . Done in five minutes. If you have an existing MUI Drawer, map it to Sheet , not Drawer . Save yourself the half-day. 3. Select inside a Dialog scrolled the page, not the list When you put a Radix Select inside a Radix Dialog , the listbox renders in a portal at document.body . By default, it stacks above the dialog correctly. But if you have any ancestor with transform , filter , or overflow: hidden between the trigger and the body, the portal can render in the wrong scroll context. The symptom: scrolling the open dropdown scrolled the page underneath it instead. {items.map(...)} position="popper" plus auditing your dialog wrapper for stray transform rules fixes it. We had one offending motion.div wrapping the dialog body for an entrance animation. That single property pulled every Radix portal inside it into the wrong stacking context. 4. Replacing react-select meant rewriting every consumer We weren't only on MUI we also had react-select for anything with async loading, multi-select, or custom option rendering. The Radix Select is great, but it's a different beast. Single value. No native async. No tag-style multi. We built our own on top of Radix Popover + Command (which is what shadcn's Combobox example does), with three variants: single, multi, async-multi. That covered ~90% of consumers. The last 10% were genuinely complex (nested options, virtualized lists), and we kept those on react-select for now. Lesson: budget for "we will not migrate every Select." If a consumer has 1,200 options or custom rendering that costs a week to port, leave it. A consistent design system has 95% coverage and a clear escape hatch. 100% coverage is a six-month project nobody asked for. 5. Theme tokens didn't map 1:1 MUI's theme is a JS object. Shadcn's is CSS variables. Most things mapped fine. Three did not: - Spacing scale. MUI uses an 8px base. Tailwind uses 4px. Half our paddings shifted by 4px during the cutover. Caught most of it in review; missed a few until QA flagged tight spacing on filter chips. - Elevation/shadow. MUI has 25 elevations. Shadcn has shadow-sm ,shadow ,shadow-md ,shadow-lg . We collapsed to four levels and the design team agreed the original 25 was overkill anyway. - Color tokens for state. MUI exposes theme.palette.error.main etc. Tailwind exposes--destructive and friends. We added a small theme shim during transition so existing components could read both: // theme-bridge.ts export const tokens = { errorMain: 'var(--destructive)', primaryMain: 'var(--primary)', // ... } as const This let us migrate components incrementally without touching every consumer of theme.palette in one go. 6. React 19 StrictMode crashed a third-party portal Halfway through the migration we upgraded React 18 to 19. react-apexcharts (we still use it for some charts) crashed inside StrictMode's double-mount cycle a known issue with libraries that touch DOM imperatively in useEffect without idempotent cleanup. Not a Shadcn problem, but it landed in the same migration window because everything moved at once. The fix was upstream-style: a thin client wrapper that mounts the chart only once mounted is true, plus a key on the wrapper to force a clean remount on route changes. function StableChart({ options, series }: Props) { const [mounted, setMounted] = useState(false) useEffect(() => { setMounted(true) }, []) if (!mounted) return null return } If you're running multiple migrations concurrently UI library, React version, monorepo restructure freeze one of them for two weeks while the others stabilize. We didn't, and lost three days to crossed wires. 7. Unmount animations got swallowed during route transitions Radix's exit animations rely on data-state="closed" staying on the DOM long enough for the CSS transition to play. Next.js App Router unmounts the page immediately on navigation. Your dialog disappears with no exit animation if it's still open during a route change. This is mostly cosmetic, but it looked broken. The fix: useEffect(() => { return () => setOpen(false) // clean close on unmount }, [setOpen]) Calling setOpen(false) synchronously on unmount lets the close animation finish on the way out. Pair this with a only if you actually need persistent DOM (we didn't). What we'd do again After all of that yes, still worth it. Specifically: - Bundle dropped meaningfully. We measured. Some routes lost 90KB+ of MUI internals. - Composition replaced configuration. Radix gives you the primitive; you write the wrapper that fits your app. It's more code on day one, far less on day 200. - Theming stopped being two systems. One source of truth in CSS variables. Designers can read it. Engineers can change it. Storybook shows it. - The team stopped saying "MUI is doing something weird." That sentence used to come up once a week. What we'd do differently - Don't run three migrations at once. Pick one. Land it. Then the next. - Map components before writing code. Make the spreadsheet: every MUI component, what it maps to, who's responsible, edge cases. Boring, saves weeks. - Build the consumer-facing wrappers first. Our , , shouldn't expose Radix props directly. The wrapper is the API your app talks to. Keeps the migration honest and the future swap easier. - Leave 5% on the table. The last few hold-outs aren't worth the cost. Kill that perfectionism early. Top comments (0)
Comments
No comments yet. Start the discussion.