I've built three production design systems, and I can tell you the difference between "a folder of React components" and an actual scalable design system comes down to one thing: typing your design tokens and enforcing consistency at compile time, not runtime.
Most teams start with a component library and hope theming magics itself into existence. That's how you end up with six different shades of blue and components that break when someone changes CSS variables. I prefer building the constraint layer first—define your design tokens as TypeScript types, generate CSS from them, and make it impossible to ship inconsistent UI.
The Problem With Traditional Approaches
When I started CitizenApp, I inherited a Tailwind-only approach. Seemed fine until we needed dark mode, custom branding per tenant, and WCAG AAA compliance across all themes. Suddenly I'm managing CSS variable overrides in 15 places, components are picking arbitrary colors, and nothing validates.
Here's what burned me: CSS-in-JS without types is just inline styles with extra steps. You get no autocomplete, no exhaustiveness checking, no way to enforce "use only these colors." And if you use Tailwind classes everywhere, good luck swapping themes or detecting when someone used a color that doesn't exist in your design system.
I switched to a hybrid: TypeScript-first design tokens with CSS variable generation, paired with Tailwind for utilities. This gives me strict theming, type safety, and the developer experience I actually want.
Building a Type-Safe Design System
Let's start with the foundation—design tokens as TypeScript enums and objects:
// src/tokens/colors.ts
export const colorTokens = {
// Semantic colors (what I actually use in components)
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
900: '#0c2d6b',
},
secondary: {
50: '#faf5ff',
500: '#a855f7',
900: '#5b21b6',
},
neutral: {
0: '#ffffff',
50: '#f9fafb',
100: '#f3f4f6',
500: '#6b7280',
900: '#111827',
},
// Status colors (always the same meaning)
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6',
} as const;
export type ColorToken = keyof typeof colorTokens;
export type ColorScale = keyof typeof colorTokens[ColorToken];
Now create a theme configuration that's actually typed:
// src/tokens/theme.ts
import { colorTokens } from './colors';
export const themes = {
light: {
colors: {
background: colorTokens.neutral[0],
foreground: colorTokens.neutral[900],
border: colorTokens.neutral[200],
primary: colorTokens.primary[600],
primaryHover: colorTokens.primary[700],
success: colorTokens.success,
error: colorTokens.error,
},
},
dark: {
colors: {
background: colorTokens.neutral[900],
foreground: colorTokens.neutral[50],
border: colorTokens.neutral[700],
primary: colorTokens.primary[500],
primaryHover: colorTokens.primary[400],
success: colorTokens.success,
error: colorTokens.error,
},
},
} as const;
export type ThemeName = keyof typeof themes;
export type ThemeConfig = (typeof themes)[ThemeName];
export type SemanticColor = keyof ThemeConfig['colors'];
// Generate CSS variables programmatically
export function generateThemeCss(themeName: ThemeName): string {
const theme = themes[themeName];
const cssVars = Object.entries(theme.colors)
.map(([key, value]) => `--color-${key}: ${value};`)
.join('\n ');
return `[data-theme="${themeName}"] {\n ${cssVars}\n}`;
}
Generate actual CSS at build time (or runtime in dev):
// src/tokens/generate.ts
import { themes } from './theme';
import fs from 'fs';
export function generateCssFile() {
const allThemes = Object.keys(themes)
.map((themeName) => generateThemeCss(themeName as ThemeName))
.join('\n\n');
const css = `/* Auto-generated theme CSS. Do not edit manually. */\n${allThemes}`;
fs.writeFileSync('src/styles/themes.css', css);
console.log('✓ Generated theme CSS');
}
if (require.main === module) {
generateCssFile();
}
Type-Safe Components Using Tokens
Now your components are type-constrained. This is where the magic happens:
// src/components/Button.tsx
import { SemanticColor, themes } from '../tokens/theme';
import { ReactNode } from 'react';
interface ButtonProps {
variant?: 'primary' | 'secondary' | 'ghost';
color?: SemanticColor;
size?: 'sm' | 'md' | 'lg';
children: ReactNode;
className?: string;
}
export const Button = ({
variant = 'primary',
color = 'primary',
size = 'md',
children,
className = '',
}: ButtonProps) => {
const sizeClasses = {
sm: 'px-2 py-1 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg',
};
// This TypeScript ensures `color` is always a valid semantic color
const baseStyle = `
bg-[var(--color-${color})]
hover:bg-[var(--color-${color}Hover)]
text-[var(--color-foreground)]
border border-[var(--color-border)]
rounded-md
transition-colors
${sizeClasses[size]}
${className}
`;
return (
<button
className={baseStyle}
data-variant={variant}
>
{children}
</button>
);
};
The killer feature: if you typo a color name or try to use a non-existent semantic color, TypeScript catches it immediately. No runtime surprise, no shipped bug.
Multi-Tenant Theming in Practice
Here's how I handle per-tenant branding in CitizenApp:
// src/hooks/useTheme.ts
import { useContext, useMemo } from 'react';
import { ThemeContext } from '../context/ThemeContext';
import { themes, type ThemeName } from '../tokens/theme';
export function useTheme(themeName?: ThemeName) {
const contextTheme = useContext(ThemeContext);
const resolvedTheme = themeName || contextTheme?.theme || 'light';
// Memoize to prevent re-renders when theme doesn't change
return useMemo(() => themes[resolvedTheme], [resolvedTheme]);
}
At the app level:
// src/App.tsx
import { ThemeProvider } from './context/ThemeContext';
import { useThemeFromUser } from './hooks/useThemeFromUser';
export default function App() {
const userTheme = useThemeFromUser(); // 'light' | 'dark' from JWT
return (
<ThemeProvider theme={userTheme}>
<div data-theme={userTheme}>
<YourApp />
</div>
</ThemeProvider>
);
}
Gotcha: CSS Variable Specificity and Dark Mode
When I first deployed this, I discovered Tailwind's opacity modifiers don't work with CSS variables the way you'd expect. bg-[var(--color-primary)]/50 doesn't let you control opacity on a custom property—you need to either use RGBA values or double-define with fallbacks.
I worked around this by extending Tailwind config to define color utilities that respect my tokens:
// tailwind.config.js
export default {
theme: {
colors: {
primary: 'var(--color-primary)',
secondary: 'var(--color-secondary)',
// ... all semantic colors
},
},
};
Now bg-primary works perfectly and respects your theme's CSS variables at runtime.
Why This Matters
You avoid the graveyard of "design system" projects that nobody uses because they're:
- Too rigid (can't adapt to real product needs)
- Too loose (developers make decisions, inconsistency spreads)
- Poorly typed (nobody discovers features because there's no autocomplete)
A TypeScript-first design system with generated CSS is the sweet spot: constraints at compile time, flexibility at runtime, and developer experience that makes people actually use it.
This approach scales. I've used this pattern across five projects now, and it's the only theming strategy where adding a new color or changing a theme doesn't require touching component code.
Top comments (0)