Logo Gerardo Perrucci - Full Stack Developer

Why Migrating Design Systems Away from Styled-Components

Design System Migration

For a long time, styled-components was my default choice for building Design Systems. It solved the scoping problem, made dynamic styling intuitive based on props, and felt like "just writing JavaScript."

But the landscape has shifted. With the rise of React Server Components (RSC) and the relentless push for better Core Web Vitals, the trade-offs of runtime CSS-in-JS are becoming harder to justify.

Recently, I conducted a deep-dive analysis to select the next styling engine for a B2B platform migration I’m architecting. I compared our current setup against modern contenders like Tailwind CSS, CSS Modules, and Zero-Runtime CSS-in-JS (specifically Vanilla Extract).

Here is my analysis, the data behind the decision, and what I’m moving to.

Table of Contents

The Problem with Runtime CSS-in-JS

To understand why we are moving, we have to look at how styled-components works. When your application loads, the library parses your style definitions, generates CSS classes on the fly, and injects them into the <head>.

This introduces two significant bottlenecks:

  1. Runtime Overhead: The browser has to do extra JavaScript work (parsing/injecting) before it can paint the screen. On low-end devices or large dashboards, this adds measurable latency to the First Contentful Paint (FCP).
  2. The Server Component Friction: In the Next.js App Router world, styled-components forces you to mark significant parts of your tree with "use client". This negates many benefits of RSCs, as you cannot render styled components purely on the server without hydration costs.

As I’ve written about before, modern architectures require us to think about where our code runs: on the server or the client. Runtime styling libraries blur that line in a way that is becoming increasingly friction-heavy.

The Contenders: My Evaluation

I evaluated three main paths forward. Here is the summary of my findings.

1. The Utility-First Giant: Tailwind CSS

Tailwind has won the popularity contest, and for good reason. It’s atomic, scans your files at build time, and generates a tiny CSS file containing only what you use.

  • Pros: Zero runtime cost. Excellent collinearity (styles live with markup). Copy-paste velocity is unmatched.
  • Cons: "Class soup" can make complex components hard to read. Enforcing strict design system tokens requires discipline (or extra tooling).

Verdict: Incredible for products, but sometimes lacks the strict "API" feel I want for a rigorously constrained Design System.

2. The Traditionalist: CSS Modules

This is the standard "boring" choice. It scopes styles locally by default and requires zero runtime libraries.

  • Pros: Native CSS support. No lock-in. Works perfectly with Server Components.
  • Cons: You lose the "prop-based" dynamic styling. You have to manually map props to class names, often resulting in verbose className logic.

Verdict: Reliable, but feels like a step backward in Developer Experience (DX) compared to the component-based model we are used to.

3. The Modern Hybrid: Zero-Runtime CSS-in-JS (Vanilla Extract)

This category promises the best of both worlds: you write code that looks like TypeScript objects, but it compiles away to static CSS classes at build time.

  • Pros: 100% Type-safe styles. Zero runtime cost. Great compatibility with RSCs since it generates static .css files.
  • Cons: Requires a bundler plugin. You generally need separate .css.ts files, which breaks the "single file component" mental model slightly.

Verdict: The "Architect’s Dream." It offers strict constraints and type safety without the performance penalty.

The Code Comparison

Let's look at a concrete example: a Button component with primary and secondary variants.

The Old Way: Styled-Components

This relies on runtime props evaluation.

import styled from 'styled-components';

const Button = styled.button<{ $variant: 'primary' | 'secondary' }>`
  padding: 12px 24px;
  border-radius: 8px;
  border: none;
  background-color: ${props => props.$variant === 'primary' ? '#0070f3' : '#eaeaea'};
  color: ${props => props.$variant === 'primary' ? 'white' : 'black'};
  
  &:hover {
    opacity: 0.9;
  }
`;

The New Way: Tailwind + CVA (Class Variance Authority)

This is the pattern I have been adopting most frequently. We use CVA to bring back the "interface" of a component while using Tailwind for the engine.

import { cva, type VariantProps } from 'class-variance-authority';

// 1. Define the variants
const buttonVariants = cva(
  // Base styles
  "py-3 px-6 rounded-lg border-none transition-opacity hover:opacity-90 font-medium",
  {
    variants: {
      intent: {
        primary: "bg-blue-600 text-white",
        secondary: "bg-gray-200 text-black",
      },
    },
    defaultVariants: {
      intent: "primary",
    },
  }
);

// 2. Create the component
interface ButtonProps 
  extends React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {}

export const Button = ({ intent, className, ...props }: ButtonProps) => {
  return (
    <button 
      className={buttonVariants({ intent, className })} 
      {...props} 
    />
  );
};

This approach allows me to keep the API my team loves (<Button intent="primary" />) but completely eliminates the runtime styling cost.

Decision Rule: Which one should you choose?

After migrating several enterprise-grade systems, here is the heuristic I use:

  • Use Tailwind + CVA if: You are building a product-focused application or a flexible internal tool where iteration speed is the top priority. The ecosystem support (shadcn/ui, etc.) makes this the pragmatic default for 90% of teams.
  • Use Vanilla Extract (Zero-Runtime) if: You are building a strict, multi-brand Design System meant to be consumed by hundreds of disparate developers. If you need to enforce type-safety on your tokens (e.g., ensuring devs only use vars.color.brand and never hex codes), the TypeScript integration is superior.
  • Stick with Styled-Components if: You have a massive legacy codebase with no performance issues, and you aren't planning to move to React Server Components anytime soon. Migration is expensive; don't do it just for the hype.

Final Thoughts

Technology decisions are rarely about "right" or "wrong". They are about trade-offs. Five years ago, styled-components was the correct trade-off for component isolation. Today, with the browser baseline rising and the architectural shift to the server, static extraction is the winner.

I am currently betting on Tailwind with CVA for application development and exploring Vanilla Extract for strict library development. The goal is always the same: a great developer experience that doesn't tax the user's browser.

References

Have questions about this topic?

Schedule a call to discuss how I can help with your project

Why Migrating Design Systems Away from Styled-Components | Full Stack Developer - Gerardo Perrucci