Context
The app started on Emotion, which was chosen deliberately. Runtime CSS-in-JS still runs a lot of production code, and the project wanted two kinds of experience with it: how a legacy styling model behaves under current React, and how far it can lean on modern platform CSS. That second goal is why the design tokens were plain CSS custom properties from the start rather than JS values.
The project is built on TanStack Start with streaming SSR, React 19, and React
Aria Components. The runtime nature of Emotion injecting styles during streamed
rendering produced hydration errors, flashes of unstyled content (a known
react-spectrum issue), and post-hydration rerenders. The RAC Select
component’s styling in particular forced a dual-render workaround, with a
non-RAC placeholder on the server and a swap to the real component after
hydration. This meant maintaining rendering parity between two components with
different markup, API compromises around the component, and a loss of state
continuity across hydration.
Starting from modern tokens also limits what this migration demonstrates.
Because the tokens were already custom properties, the port skipped work a real
legacy codebase would face: JS-held token values, and runtime styling utilities
that would need moving to native CSS like color-mix and calc. Those are
baseline web features now, and artificially writing a legacy codebase just to
convert it was not deemed useful. The migration was therefore smaller than a
true legacy port would be, which was understood from the start.
The replacement had four requirements: build-time static CSS, current adoption in greenfield work, a philosophy other than utility classes (utility-first is deliberately explored in a separate project), and structure / patterns provided by the tool rather than invented here (typed tokens, variants, conditions).
Decision
Migrate to Panda CSS. Styles are extracted to static CSS at build time. Tokens
and semantic tokens live in the Panda config as the single source of truth. RAC
state styling maps to named conditions over the same data attributes the Emotion
code already targeted (_hovered, _pressed, _selected). The shared UI
components are config recipes whose classes land in a cascade layer below
utilities, so a caller’s css prop override wins structurally instead of by
merge-rule convention. Components accept a typed override contract: a css prop
merged at the object level, with className accepted and merged for full
compatibility with existing tooling (TanStack Router’s createLink utility in
particular expects to inject an active class name).
The migration ran in five staged PRs, each shipped to main: RAC idiom cleanup first, then install, the UI layer, the app code, and finally removing Emotion. The stated end condition was idiomatic Panda and idiomatic RAC with no cruft left from before or during the migration path.
The dual-render Select workaround was deleted outright: with static CSS in the
document there is nothing to inject at hydration time. The migration removed a
net 454 lines.
Alternatives considered
StyleX
Meta’s build-time system. It is a viable alternative and actively developed. Rejected due to StyleX’s approach of conditional style objects through render props and outright restricting attribute selectors, which would have meant giving up the data-attribute styling the codebase already relied on. In addition, its Vite integration was noticeably less mature than its webpack tooling at the time.
vanilla-extract
Build-time, typed, and a reasonable fit. Not chosen due to its trajectory: adoption has been flat since 2023, and the goal was to move to tools that are still being actively chosen.
CSS Modules with modern CSS
A strong candidate that comfortably aligns with a web native approach. However, it ships without built-in patterns such as typed tokens, variants, and conditions, requiring them to be invented in-project. Inventing them here was a stated non-goal.
Tailwind
Ruled out at the requirements level rather than on merit: a utility-class approach is deliberately explored elsewhere, and the point of this styling layer is the token-and-recipe philosophy.
Consequences
- Styles must be statically analyzable. Computed values move into tokens or CSS custom properties, and the extractor’s failure mode is silent: a style object it cannot see emits no CSS and no error. That property produced real bugs during the migration and led to a guard test asserting every declared recipe variant has generated CSS.
- The cascade-layer ordering retired a class of merge rules. Under Emotion, wrapper components needed conventions about merge order and property-key forms for overrides to resolve predictably. With recipes in a layer below utilities, override precedence is structural.
- Two footguns led to explicit patterns to avoid repeating:
- Panda does not reconcile a shorthand and its longhand across merged objects, so both classes ship and stylesheet order decides (determined by layer order combined with Panda’s property sorting). Component bases uniformly use shorthands where callers override.
- TanStack’s
createLinkinjects a defaultclassNameon active links, so a component that replaces instead of mergesclassNamesilently loses its styles.
- The Panda-plus-RAC pairing leaves a mix of factory-generated and hand-written
components, since the
styled()factory cannot carry generics or component logic. That mix matches how the main Panda-based component libraries are built and is the accepted shape, not a leftover. - The build gains a codegen step, and the generated
styled-system/directory is gitignored and rebuilt on install. - The SSR problem class is eliminated entirely. Styles ship in the document and the select renders correctly from the server with no client-side swap.