DEV Community

Ugur Aslim
Ugur Aslim

Posted on • Originally published at uguraslim.com

Design Systems in React: Building Themeable, Typed Component Libraries with TypeScript

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];
Enter fullscreen mode Exit fullscreen mode

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}`;
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
};
Enter fullscreen mode Exit fullscreen mode

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]);
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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
    },
  },
};
Enter fullscreen mode Exit fullscreen mode

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)