Tokens, Not Themes: Migrating a React Design System to CSS Variables Without Breaking Production
A practical walkthrough of moving a React design system from styled-components themes to CSS custom properties and design tokens — without a big-bang rewrite or a Monday morning rollback.

We inherited a React design system last year that shipped fine in 2021 and started bleeding bundle size and runtime cost by 2025. The fix wasn't another rewrite — it was deleting JavaScript and letting the browser do the work. Here's how we moved a styled-components theme to CSS custom properties without a flag day.
Why the JS-theme era is ending
The original system was textbook 2020: a ThemeProvider, a deeply nested theme object, and styled-components reading props.theme.colors.primary on every render. It worked. It also meant:
- Every theme token lived in the JS bundle, shipped to every user.
- Theme switching forced a React re-render of every styled component.
- Server Components in the Next.js App Router couldn't read the theme without prop-drilling or context gymnastics.
- Dark mode flashed on first paint because the theme resolved after hydration.
CSS custom properties solve all four. They live in the stylesheet, cascade naturally, switch with a class toggle, and are readable from any rendering environment — including a static HTML file served from the edge.
The catch: you can't just :root { --color-primary: blue; } and call it a day. A real design system has token tiers, theme variants, type safety, and consumers who will absolutely notice if Button changes by two pixels.
The token model we landed on
We split tokens into three tiers, which is not novel but worth being explicit about:
- Primitive tokens — raw values.
--blue-500: #2563eb. No semantics. - Semantic tokens — intent-based.
--color-action-primary: var(--blue-500). These are what components consume. - Component tokens — scoped.
--button-bg-primary: var(--color-action-primary). Optional, but useful for components with many states.
Components only read semantic or component tokens. Primitives are an implementation detail. This matters because when you theme — dark mode, a high-contrast variant, a white-label tenant — you remap semantics, not primitives.
Generating tokens from a single source
We author tokens in TypeScript so designers and engineers can share one source, then emit CSS at build time. Style Dictionary is the boring, correct choice, but a 40-line script works for smaller systems:
// tokens/source.ts
export const primitives = {
blue: { 500: '#2563eb', 600: '#1d4ed8' },
gray: { 50: '#f9fafb', 900: '#111827' },
} as const;
export const semantic = {
light: {
'color-bg-surface': primitives.gray[50],
'color-text-default': primitives.gray[900],
'color-action-primary': primitives.blue[500],
},
dark: {
'color-bg-surface': primitives.gray[900],
'color-text-default': primitives.gray[50],
'color-action-primary': primitives.blue[600],
},
} as const;
export type SemanticToken = keyof typeof semantic.light;
A short build step writes tokens.css:
:root,
[data-theme='light'] {
--color-bg-surface: #f9fafb;
--color-text-default: #111827;
--color-action-primary: #2563eb;
}
[data-theme='dark'] {
--color-bg-surface: #111827;
--color-text-default: #f9fafb;
--color-action-primary: #1d4ed8;
}
The TypeScript export gives us autocomplete in components; the CSS gives us runtime themability with zero JS.
Surviving the migration without a freeze
The team that wrote the original system was gone. There were 1,200+ usages of theme.colors.* across two product apps and a marketing site. A big-bang PR was off the table.
We ran the migration as three overlapping phases, all behind the same shipped artifact.
Phase 1: dual-publish the tokens
We kept the existing JS theme object alive and made it a derivative of the new TypeScript source. Same shape, same keys, same values. Nothing changed for consumers. This took half a day and unblocked everything else, because now there was one source of truth even if two consumption paths existed.
Phase 2: rewrite primitives to read CSS variables
We changed the base Box, Text, and Button primitives to read from var(--color-...) instead of props.theme.colors.*. Because the new tokens.css was loaded globally, the visual output was identical. We caught two regressions: a button hover that relied on a JS color-mix function (replaced with color-mix() in CSS), and a tooltip that read the theme inside a useEffect to compute an offset (we deleted the effect; it wasn't needed).
This is the phase where you want visual regression tests. We use Playwright with per-component snapshots. The diff bar should be near zero. If it isn't, your old theme and new tokens disagree somewhere, and you want to know before the redesign cycle starts blaming you.
Phase 3: deprecate the JS theme
Only after primitives were stable did we touch app-level code. A codemod rewrote ${({theme}) => theme.colors.action.primary} to var(--color-action-primary). We left the ThemeProvider in place but emptied it, then removed it in a follow-up release once nothing referenced useTheme().
The key discipline: never let phases overlap inside a single component. A Button.tsx that reads both theme.colors and var(--...) is a debugging trap. PR template enforced it.
Killing the dark-mode flash
The classic FOUC pattern with JS themes: server renders light, client hydrates, user's prefers-color-scheme: dark kicks in, screen blinks. With CSS variables and the App Router, you fix this with a small inline script in the root layout that sets data-theme before paint.
// app/layout.tsx
const themeScript = `
(function () {
try {
var stored = localStorage.getItem('theme');
var system = window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark' : 'light';
document.documentElement.dataset.theme = stored || system;
} catch (_) {
document.documentElement.dataset.theme = 'light';
}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body>{children}</body>
</html>
);
}
Yes, it's an inline script. Yes, suppressHydrationWarning is required because the server can't know the user's preference. No, this does not violate any reasonable CSP if you use a nonce. We've shipped this pattern on sites with strict CSP and it's fine.
In our experience this moves the perceived theme-switch flash from "obvious blink" to "invisible," and CLS stays at zero because nothing reflows.
Type safety without re-introducing JS overhead
The loudest objection from the team was losing autocomplete. CSS variables are stringly-typed. We split the difference with a typed helper:
// tokens/var.ts
import type { SemanticToken } from './source';
export function token(name: SemanticToken): string {
return `var(--${name})`;
}
Usage:
import { token } from '@/tokens/var';
export function Card({ children }: { children: React.ReactNode }) {
return (
<div
style={{
background: token('color-bg-surface'),
color: token('color-text-default'),
}}
>
{children}
</div>
);
}
The function call compiles away to a string in any reasonable build, and TypeScript yells if you typo a token name. For styled-components or vanilla-extract holdouts, the same helper drops into template literals.
Performance results worth measuring
We didn't get a dramatic Lighthouse jump because the original site was already reasonably fast. What we did get:
- Roughly a 30–40 KB reduction in client JS once
styled-componentsand the theme object were removed. Your mileage will vary based on how much of your styling moves out of JS. - Theme toggle latency dropped from "a noticeable frame" to "imperceptible," because no React tree rerenders — just a class change on
<html>. - INP improved on pages with large component trees, because style recalculation no longer cascades through React.
- Server Components could finally read theme-aware styles without prop-drilling, which simplified our App Router refactor for free.
None of these are guaranteed. Measure your own. If your design system is small or you've already moved to a zero-runtime CSS-in-JS solution like vanilla-extract, the JS savings will be smaller, but the architectural cleanup still earns its keep.
Where we'd start
If you're staring at a styled-components or Emotion design system in 2026 and wondering whether to migrate: start with the token model, not the components. Author your tokens in TypeScript, emit both a CSS file and a JS export, and ship that alongside the existing theme. You haven't changed anything yet — but now you have one source of truth.
Then pick the three most-used primitives (Button, Text, Box in our case) and migrate them behind visual regression tests. If those land cleanly, the rest of the system follows in afternoons, not quarters. The boring path is the fast path.
If you'd rather not do it alone, this is the kind of work our team does week-in week-out — see our web development services for how we approach design system audits.
Want a team like ours?
72Technologies builds production software for the kind of teams who actually read this blog.
Start a projectKeep reading

Partial Prerendering in Production: What Breaks When You Turn It On
Partial Prerendering looked like free performance on the demo slide. Then we shipped it. Here's what actually breaks when you flip the flag on a real Next.js app — and how we'd roll it out now.

Server Actions at Scale: The Hidden Cost of Treating Them Like API Routes
Server actions feel like free API endpoints. They aren't. Here's what we learned shipping them to production traffic, and the patterns that kept latency and bills in check.

The `use` Hook Is a Footgun: When to Reach for It in React 19 (and When to Walk Away)
React 19's `use` hook looks like a cleaner way to read promises and context. In production it has sharp edges. Here's where it earns its keep and where it quietly tanks your app.
