I’ve been working with two versions of a component library at PickYourTrail over the past couple of years, and finishing up the second one has clarified something I wasn’t fully sure about when I built the first.
The first version of Atom was built on Stitches.js. @atom-web/core uses @stitches/react as its styling foundation, the stitches.config.ts file sets up the theme tokens, the styled factory, the css helper, dark/light theme variants. The package is still at version: 0.0.1-alpha.16 and it’s what the original Plato frontend used.
The second version, what we’re moving to as we build plato-next, is built on Tailwind, with a composition model closer to what shadcn/ui popularized. Different styling layer, same purpose.
I want to be honest about what changed and what didn’t, because I think there’s a real lesson in here about where to make long-term architectural bets.
What Stitches gave us, and why it was a reasonable choice
Stitches was genuinely good for the stage we were in when we built the original Atom.
The core selling point: design tokens as a first-class concept baked into the styling system. When you call styled('div', { color: '$primary600' }), the $primary600 token is defined in the Stitches config, type-checked, and automatically surfaced in theming. The relationship between your design language and your component styles is explicit and enforced by the tooling.
// stitches.config.ts, the theme tokens are the contract
const stitches = createStitches({
...commonTheme,
theme: {
...commonTheme.theme,
colors: {
...commonTheme.theme.colors,
...lightTheme.colors,
},
},
});
export const styled = stitches.styled;
export const theme = stitches.theme;
That config is the source of truth for design tokens. Every component in the library talks to it. When you update a color, every component that uses that token picks it up. For a team building a design language from scratch, that level of integration was appealing.
The other thing Stitches did well: SSR-compatible CSS generation (getCssText()) and a clean variant system. You could define component variants that composed with each other without specificity fights.
It was a reasonable choice. I don’t want to pretend it was a mistake just because the ecosystem moved.
What changed in the ecosystem
Tailwind’s ecosystem grew fast between 2021 and 2023. The utility-first model that felt controversial a couple of years earlier became the default for a lot of new projects. More importantly, the tooling around it, PostCSS integration, Tailwind IntelliSense, the JIT compiler, made the development experience competitive.
Then shadcn/ui landed and clarified something that had been fuzzy: you can get the benefits of a well-designed component library without taking on a library dependency. The shadcn model is “copy these components into your project and own them.” You get a sensible starting point for accessible, composable UI, and you can modify anything without waiting for a PR to merge.
That model was hard to ignore for two reasons:
First, the ownership model fit what we actually wanted. The whole point of building Atom was to own our UI layer, not to be stuck waiting on external maintainers. shadcn’s approach makes that explicit from the start rather than requiring you to fork a package.
Second, it aligned the component distribution model with how we were already thinking about plato-next. We’re building a Next.js + Turborepo monorepo where the UI package is owned by us. Tailwind fits that model naturally, you configure it once, generate utility classes, and your components use those classes.
What Tailwind doesn’t give you (that Stitches did)
I want to be precise here because I think people sometimes oversell the switch.
Stitches had typed design tokens. When you typed color: '$, IntelliSense showed you every valid token. Tailwind’s utility classes are typed in Tailwind’s own schema, but you lose that specific property: the relationship between “what the designer calls things” and “what the component calls things” is less enforced.
You can rebuild this with a CSS custom property layer (--color-primary-600: ... in your Tailwind config) and the TypeScript layer starts to close the gap, but it’s not as tight as Stitches’s model.
The other thing Stitches had: variant composition. When you define size: { sm: {...}, md: {...} } and variant: { primary: {...}, ghost: {...} }, Stitches handles the cross-product. With Tailwind you’re usually managing this with cva (class-variance-authority) or similar utilities. It works fine but it’s more manual.
These are real things we gave up. I don’t think the tradeoffs were wrong, but they were real.
The thing that didn’t change: Radix UI
This is the most important observation from the whole migration.
The primitive layer, Radix UI, never changed. Both Atom versions use Radix. Both the Stitches-era components and the Tailwind-era components sit on top of Radix’s accessible, unstyled primitives.
@react-aria/overlays was in @atom-web/core’s dependencies. Radix Dialog, Radix Select, Radix Tooltip, these headless primitives provide the behavior (keyboard navigation, ARIA attributes, focus management, portal handling) without dictating the appearance. The appearance is what changed. The behavior layer didn’t have to.
This retrospectively validated something I wasn’t fully conscious of when building the first version: the stable layer in a component system is the behavior layer, not the styling layer.
CSS approaches change faster than accessibility patterns. Utility-first vs. CSS-in-JS is a styling conversation. Whether your Dialog traps focus correctly is an accessibility conversation. These operate at different rates of change.
If you bet your long-term architectural stability on the styling layer, you’re betting on something that will probably change on a 2-4 year horizon. If you bet on the behavior layer, Radix, or React Aria, or something equivalent, that’s a longer-lived bet.
I’m more confident about this now than I was at the start of 2023.
What the ownership model actually means
The shadcn/ui shift also surfaced something about what “design system” means.
The original Atom had a package distribution model: you npm install @atom-web/core, you get the components, and when you want changes you open a PR against the atom monorepo. That’s the traditional component library model.
plato-next’s UI layer is closer to the shadcn model: the components live inside the monorepo, in packages/ui, and they’re owned directly. No external dependency to wait on. No versioning headache when you need to change a component that was built for a slightly different use case.
The trade is: you give up the clean boundary of “UI library” and “consumer.” The components are part of your codebase, not a dependency. That means they’re your maintenance responsibility.
For an internal tool at our scale, that trade is worth it. We’re not distributing to external consumers. We’re not trying to support multiple product lines with different design languages. We have one product, one team, one design language. Owning the components directly is less overhead than the library boundary.
The lesson
If I had to reduce this to one thing: style the components you own, don’t own the styling engine.
Stitches is a styling engine. Tailwind is also, in a sense, a styling engine, but it’s one the whole ecosystem has aligned around, which changes its operational characteristics dramatically.
The bet that held through both versions: Radix UI primitives as the behavior foundation. That’s the layer I’d point someone to now if they were starting from scratch. Get your accessible, composable behavior primitives right. Style them with whatever the current best tool is. Accept that the styling layer will probably change in a few years and design for that changeability.
Atom v2 isn’t a rejection of what Atom v1 got right. It’s a refinement of where to make the stable bet.