Skip to content

Build1 publisher3 min readPublished

Four CSS systems in one bundle: the migration blocker was in the config, not the components

A design engineer got emotion, two legacy utility frameworks and Tailwind 4 sharing pages by rewriting selectors at build time. Three earlier attempts had been fighting the wrong layer.

The Engineer · Build desk

Drafted by a language model from the sources cited here and checked against its claim ledger before publication. How we use AISend a correction

What happened

  • James Coombs is a design engineer who migrated a large frontend from Material UI to a custom design system built on Radix and Tailwind.
  • Four CSS systems had to run simultaneously in the same bundles and on the same pages without collision: Material UI's emotion CSS-in-JS runtime, two legacy utility frameworks (one in-house, one open source) alongside an in-house component library, and the new design system on Tailwind CSS 4.
  • The received answer was that coexistence could not be done without component-level isolation, and three approaches had already been considered and rejected on that basis; nobody had tested it at the CSS layer.
  • At roughly 960 files as of April 2026, spread across the whole monorepo, component-level isolation would mean touching every file before migration could begin, which Coombs describes as a rewrite rather than a migration strategy.
  • Manual class prefixing would require 70-plus files of mechanical changes plus every new component authored with the prefix, and scales linearly with component count.

Compiled by The EngineerSomething wrong?How this is made

Why it matters

James Coombs, a design engineer, reports moving a large frontend off Material UI onto a custom design system built on Radix and Tailwind while four CSS systems ran at once in the same bundles and on the same pages [1][2]. The useful part is not the design system: three earlier approaches had been rejected on the assumption that coexistence requires component-level isolation, and nobody had tested the CSS layer [3].

The four systems were Material UI's emotion CSS-in-JS runtime, two legacy utility frameworks (one in-house, one open source) sitting alongside an in-house component library, and the new design system on Tailwind CSS 4 [2]. Priced as an isolation problem, the work was not a migration. The codebase was roughly 960 files as of April 2026, spread across the monorepo, and wrapping each component in a boundary means touching every one of them before the migration starts, which Coombs calls a rewrite [4].

The three rejected options all sat at the component level [3]. Manual class prefixing came to 70-plus files of mechanical edits, plus a rule that every new component be authored with the prefix, scaling linearly with component count [5]. Runtime class rewriting added complexity and latency to every component mount and broke down when third-party libraries generated their own class names [6]. Shadow DOM gave real encapsulation but broke React portals: dialogs, popovers, tooltips and dropdown menus render to document.body, and the boundary stops them inheriting theme tokens [7].

What worked was config. The PostCSS plugin postcss-prefix-selector rewrites every generated selector to sit under a scope class while the stylesheet is built [8]. `.flex { display: flex }` has specificity 0-1-0; `.ds-scope .flex` has 0-2-0, so the scoped rule beats the unscoped global, and by Coombs's account that is the entire mechanism [9]. Consuming components need zero changes; a `DesignSystemProvider` adds the `.ds-scope` class, everything inside gets the new system and everything outside keeps working [11].

The honest caveat is that there is no `!important`, so the win is not guaranteed. A legacy compound selector at the same 0-2-0 specificity ties, and source order decides; Coombs says one component in the codebase carries a comment recording exactly that, where a preflight rule ties with a legacy button's generated class, and that ties should be expected and hand-resolved [10].

Two details keep it standing. One pipeline runs five PostCSS plugins in sequence for the scoped build: Tailwind, a legacy-transform reset, postcss-prefix-selector, a layer-removal plugin and a preflight-scoping plugin [12]. Layer removal is the non-obvious one: stripping `@layer` puts design-system rules at normal cascade priority, because anything inside `@layer base` loses to any unlayered legacy CSS regardless of specificity [13]. Then portals: Dialog, Popover, Tooltip and DropdownMenu render outside the wrapper, so their content inherits nothing and buttons inside modals lose styling [14]. The first fix, a wrapper injecting the scope class around every portal, broke Radix UI's `SlotClone` ref forwarding and composed components silently lost their refs [15]. The working version is a React Context hook, `usePortalScopeClass`, that returns the class only inside the provider, applied through an inline conditional wrapper [16].

Worth watching: the total component-level cost came to four portal components at one edit each, against roughly 960 files under the isolation plan [17][18]. Every new portal primitive inherits that one edit [18]. And because `@layer` is stripped deliberately [13], upstream changes that assume layered output, plus any legacy CSS edit that lands a new 0-2-0 tie [10], are where this arrangement will next need attention.

Loading claim ledger
Loading source directory links
Loading share composer
Loading topic controls
Loading related stories