+1 (405) 383-8943

One design system, two frameworks: sharing UI during a migration off Ember

A strangler migration is not a two-week overlap. If you are moving a mature Ember app one route group at a time, Ember and the new stack will both be in production for a year, often two. Users will cross between them several times in a session. The question nobody plans for in month one, and everybody fights about in month six, is what happens to the UI layer: who owns the button, the form field, the modal, the toast, and what you do when design ships a change to all of them.

There are three workable answers. The failure mode is picking none of them and letting the new app grow a second, slightly different component library by accident.

Start with tokens, not components

Whatever you decide about components, extract design tokens first. Colors, spacing scale, type ramp, radii, shadows, z-index layers, motion durations. Publish them as one package of CSS custom properties, built from a single source (JSON or Style Dictionary output) so both stacks consume the same values at runtime.

This is cheap and it buys the thing that users actually notice. Component APIs can differ between the two apps without anyone outside engineering caring. Two shades of primary blue on adjacent screens is visible immediately, and it is the signal that tells the business the migration is going badly even when it is going fine.

In the Ember app this usually means deleting a pile of Sass variables and a theme addon and replacing their values with var(--ff-color-primary). Do it as a mechanical pass, one variable group at a time, with visual regression snapshots on the routes you have not migrated yet.

Option one: custom elements as the shared layer

Build the shared components once as custom elements — Lit is the common choice, but plain HTMLElement subclasses are fine for simple controls — and render them from both stacks.

In Ember a custom element is just a tag in a template. Attributes work, properties need a modifier or an element helper, and events come back through on. In React you need a thin wrapper for props-as-properties and event binding; React 19 improved custom element prop handling, but write the wrapper anyway so you control the typing.

Where this earns its keep: leaf components with a narrow surface. Buttons, badges, inputs, icons, spinners, tooltips, date pickers. Where it hurts: anything that owns layout composition or needs framework-native slot semantics. Shadow DOM boundaries make global form behavior, focus management, and testing more awkward than the demos suggest. Keep styles in light DOM with tokens if you can tolerate it; it avoids most of the pain.

Budget for the accessibility work explicitly. A shared custom-element select that gets keyboard and ARIA right once is a genuine win across both apps. One that gets it wrong ships the same bug twice.

Option two: two implementations, one specification

The boring option, and the one we recommend most often. Keep the Ember components where they are. Write native React components in the new app. Make the shared artifact a specification rather than code: tokens, a documented prop contract per component, and screenshots.

The cost is real — a design change touches two codebases — but it is bounded and predictable, and it does not add a third build system to a migration that is already complicated. It also keeps the Ember app easy to work in for the people still maintaining it, which matters more than it sounds when the migration stretches past plan.

There is a condition for this working: the Ember side must stop growing. Freeze the Ember component library to bug fixes and tokens. New components are built in the new stack only. Without that rule you are maintaining two moving libraries instead of one moving and one parked.

Option three: headless logic, framework-native skin

If a component's value is behavior rather than looks — a data table with sorting and virtualization, a combobox, a complex form state machine — the shareable part is plain TypeScript. Extract it into a framework-agnostic package: state, keyboard handling, validation, ARIA attribute computation. Then write a thin Glimmer component on one side and a React hook on the other.

This is more work per component than the other two options and it only pays back where the logic is substantial. Do not do it for a button.

How to decide

A rough rule from the migrations we have run:

  • Fewer than roughly thirty shared components, migration planned under eighteen months: two implementations, one specification. Do not build infrastructure you will delete.
  • A real design system team, multiple products, and a long horizon: custom elements for leaves, framework-native for composition.
  • One or two components carrying most of the product's complexity: headless logic packages for those, duplicate the rest.

What is never right is "we'll figure out the component story after the first route ships." By then the new app has its own Button, and the divergence is already paid for.

The operational details that bite

Forms. Native form participation across custom elements requires ElementInternals and attention. If your product is form-heavy, test this before committing to the custom-element path.

Testing. Decide where shared components are tested — once, in their own package, with Web Test Runner or similar — and stop duplicating those assertions in Ember acceptance tests and React integration tests.

Build. Embroider and Vite handle an external component package cleanly. Classic Broccoli builds and v1 addons do not, in our experience, without wrapper work. If the Ember app is still on the classic build, that is one more reason to prefer the two-implementation option.

Versioning. One version of the shared package in production at a time. Two apps pinned to different majors reintroduces exactly the drift you built the package to prevent.


We plan this layer explicitly in migration engagements: tokens first, a component inventory with a decision per component, and a written freeze rule for the Ember side. If you are mid-migration and the two apps have started to diverge visually, that inventory is usually a week of work and it is the week that stops the argument. Tell us what you're running and we'll talk through which option fits your component count and timeline.