HandoffPro

Design Tokens in React: Theme Provider Guide

·8 min read

TLDR

Design tokens in React enable theme switching, component consistency, and type-safe styling. You can implement tokens using CSS variables (best for theming), JavaScript objects (best for computed styles), or a hybrid approach. The Theme Provider pattern with React Context provides global token access and dynamic theme switching across your application.

Key takeaways:

  • CSS variables enable runtime theme switching without JavaScript, ideal for light/dark mode
  • JavaScript token objects provide TypeScript type safety and programmatic color manipulation
  • Theme Provider pattern with React Context gives components access to tokens anywhere in the tree
  • Hybrid approach combines CSS variables for performance with JS tokens for flexibility
  • Tools like Style Dictionary automate conversion from JSON tokens to platform-specific formats

Why Design Tokens in React?

React applications benefit from design tokens for three primary reasons:

1. Theme Switching – Users expect dark mode, high contrast mode, and custom theme support. Tokens enable switching entire color palettes by swapping a single object or CSS class, rather than updating hundreds of individual color values.

2. Component Consistency – When every Button, Card, and Input references the same --color-primary token, you guarantee visual consistency across your application. Manual color values inevitably drift over time as different developers hard-code slightly different shades.

3. Type Safety – TypeScript interfaces for token objects catch typos at compile time. Instead of backgroundColor: '#3B82F6' (easy to mistype), you write backgroundColor: tokens.color.brand.primary with full IDE autocomplete.

The challenge is choosing the right implementation approach for your React application. Let's explore three patterns.

Approach 1: CSS Variables

CSS variables (custom properties) are the most common implementation for design tokens because they work natively in browsers without any JavaScript. You define tokens once in CSS, then reference them anywhere using var().

Here's a complete CSS variables implementation:

/* tokens.css */
:root {
  /* Color tokens */
  --color-brand-primary: #3B82F6;
  --color-brand-secondary: #8B5CF6;
  --color-neutral-50: #F8FAFC;
  --color-neutral-900: #0F172A;
  --color-semantic-success: #10B981;
  --color-semantic-error: #EF4444;
 
  /* Spacing tokens */
  --spacing-1: 4px;
  --spacing-2: 8px;
  --spacing-3: 12px;
  --spacing-4: 16px;
  --spacing-6: 24px;
  --spacing-8: 32px;
 
  /* Typography tokens */
  --font-family-sans: Inter, system-ui, sans-serif;
  --font-size-sm: 14px;
  --font-size-base: 16px;
  --font-size-lg: 18px;
  --font-size-xl: 20px;
  --font-weight-normal: 400;
  --font-weight-semibold: 600;
  --line-height-tight: 1.25;
  --line-height-normal: 1.5;
 
  /* Border tokens */
  --radius-sm: 4px;
  --radius-md: 8px;
  --radius-lg: 12px;
 
  /* Shadow tokens */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
}

Now consume these tokens in a Button component:

// Button.tsx
import React from 'react';
import './Button.css';
 
interface ButtonProps {
  children: React.ReactNode;
  variant?: 'primary' | 'secondary';
  onClick?: () => void;
}
 
export function Button({ children, variant = 'primary', onClick }: ButtonProps) {
  return (
    <button
      className={`button button-${variant}`}
      onClick={onClick}
    >
      {children}
    </button>
  );
}
/* Button.css */
.button {
  font-family: var(--font-family-sans);
  font-size: var(--font-size-base);
  font-weight: var(--font-weight-semibold);
  line-height: var(--line-height-normal);
  padding: var(--spacing-3) var(--spacing-6);
  border-radius: var(--radius-md);
  border: none;
  cursor: pointer;
  transition: all 0.2s ease;
}
 
.button-primary {
  background-color: var(--color-brand-primary);
  color: white;
}
 
.button-primary:hover {
  filter: brightness(1.1);
}
 
.button-secondary {
  background-color: var(--color-neutral-50);
  color: var(--color-neutral-900);
  border: 1px solid var(--color-neutral-300);
}

Advantages: No JavaScript needed, works with any CSS approach (CSS modules, Tailwind, styled-components), native browser support.

Disadvantages: No TypeScript autocomplete, harder to manipulate programmatically (e.g., lighten a color by 10%).

Approach 2: JavaScript Token Objects

The second approach imports tokens as typed JavaScript objects. This enables TypeScript autocomplete, programmatic color manipulation, and computed styles.

First, define tokens in TypeScript:

// tokens.ts
export const tokens = {
  color: {
    brand: {
      primary: '#3B82F6',
      secondary: '#8B5CF6',
    },
    neutral: {
      50: '#F8FAFC',
      100: '#F1F5F9',
      900: '#0F172A',
    },
    semantic: {
      success: '#10B981',
      error: '#EF4444',
      warning: '#F59E0B',
    },
  },
  spacing: {
    1: '4px',
    2: '8px',
    3: '12px',
    4: '16px',
    6: '24px',
    8: '32px',
  },
  typography: {
    fontFamily: {
      sans: 'Inter, system-ui, sans-serif',
      mono: 'JetBrains Mono, monospace',
    },
    fontSize: {
      sm: '14px',
      base: '16px',
      lg: '18px',
      xl: '20px',
    },
    fontWeight: {
      normal: 400,
      semibold: 600,
      bold: 700,
    },
  },
} as const;
 
// TypeScript types for autocomplete
export type Tokens = typeof tokens;
export type ColorToken = keyof typeof tokens.color;
export type SpacingToken = keyof typeof tokens.spacing;

Now use tokens in a styled component or inline styles:

// Card.tsx
import React from 'react';
import { tokens } from './tokens';
 
interface CardProps {
  title: string;
  description: string;
}
 
export function Card({ title, description }: CardProps) {
  return (
    <div style={{
      backgroundColor: tokens.color.neutral[50],
      borderRadius: tokens.spacing[3],
      padding: tokens.spacing[6],
      boxShadow: '0 4px 6px rgba(0, 0, 0, 0.1)',
    }}>
      <h3 style={{
        fontFamily: tokens.typography.fontFamily.sans,
        fontSize: tokens.typography.fontSize.xl,
        fontWeight: tokens.typography.fontWeight.semibold,
        color: tokens.color.neutral[900],
        marginBottom: tokens.spacing[2],
      }}>
        {title}
      </h3>
      <p style={{
        fontSize: tokens.typography.fontSize.base,
        color: tokens.color.neutral[600],
      }}>
        {description}
      </p>
    </div>
  );
}

Advantages: Full TypeScript support, can compute derived values (like lighten(tokens.color.brand.primary, 0.1)), works with CSS-in-JS libraries.

Disadvantages: No runtime theme switching without re-renders, inline styles have higher specificity than CSS classes.

Theme Provider Pattern

The most flexible approach combines CSS variables with React Context to enable runtime theme switching. This is the industry-standard pattern for production React applications.

Here's a complete ThemeProvider implementation:

// ThemeProvider.tsx
import React, { createContext, useContext, useState, useEffect } from 'react';
 
type Theme = 'light' | 'dark';
 
interface ThemeContextValue {
  theme: Theme;
  toggleTheme: () => void;
}
 
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
 
export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('light');
 
  // Apply theme to document on mount and when theme changes
  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
  }, [theme]);
 
  // Load saved theme preference from localStorage
  useEffect(() => {
    const savedTheme = localStorage.getItem('theme') as Theme | null;
    if (savedTheme) {
      setTheme(savedTheme);
    }
  }, []);
 
  const toggleTheme = () => {
    setTheme(prev => {
      const newTheme = prev === 'light' ? 'dark' : 'light';
      localStorage.setItem('theme', newTheme);
      return newTheme;
    });
  };
 
  return (
    <ThemeContext.Provider value={{ theme, toggleTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}
 
export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useTheme must be used within ThemeProvider');
  }
  return context;
}

Define light and dark token sets in CSS:

/* themes.css */
:root[data-theme='light'] {
  --color-bg-primary: #FFFFFF;
  --color-bg-secondary: #F8FAFC;
  --color-text-primary: #0F172A;
  --color-text-secondary: #64748B;
  --color-border: #E2E8F0;
  --color-brand-primary: #3B82F6;
}
 
:root[data-theme='dark'] {
  --color-bg-primary: #0F172A;
  --color-bg-secondary: #1E293B;
  --color-text-primary: #F8FAFC;
  --color-text-secondary: #94A3B8;
  --color-border: #334155;
  --color-brand-primary: #60A5FA;
}

Now create a ThemeToggle component:

// ThemeToggle.tsx
import React from 'react';
import { useTheme } from './ThemeProvider';
 
export function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
 
  return (
    <button
      onClick={toggleTheme}
      style={{
        padding: '8px 16px',
        backgroundColor: 'var(--color-bg-secondary)',
        color: 'var(--color-text-primary)',
        border: '1px solid var(--color-border)',
        borderRadius: '8px',
        cursor: 'pointer',
      }}
    >
      {theme === 'light' ? '🌙 Dark' : '☀️ Light'}
    </button>
  );
}

Wrap your app in the ThemeProvider:

// App.tsx
import { ThemeProvider } from './ThemeProvider';
import { ThemeToggle } from './ThemeToggle';
import './themes.css';
 
export default function App() {
  return (
    <ThemeProvider>
      <div style={{
        minHeight: '100vh',
        backgroundColor: 'var(--color-bg-primary)',
        color: 'var(--color-text-primary)',
        padding: '24px',
      }}>
        <header>
          <ThemeToggle />
        </header>
        <main>
          {/* Your app content */}
        </main>
      </div>
    </ThemeProvider>
  );
}

This pattern provides instant theme switching without re-rendering components—CSS variables update automatically when the data-theme attribute changes.

Decision Matrix: Which Approach?

Choose your implementation based on your application's requirements:

Requirement CSS Variables JS Objects Theme Provider
Runtime theme switching ✅ Excellent ❌ Requires re-render ✅ Excellent
TypeScript autocomplete ❌ No ✅ Full support ⚠️ For theme object only
Programmatic color manipulation ❌ No ✅ Easy ✅ Via JS fallback
Performance ✅ Native CSS ⚠️ Inline styles ✅ Native CSS
Server-side rendering ✅ Works perfectly ✅ Works perfectly ⚠️ Hydration considerations
Best for Simple theming Complex computed styles Production apps with theming

Recommendation: Use Theme Provider pattern with CSS variables for most production React applications. Fall back to JS token objects only when you need programmatic color manipulation or complex computed styles.

For a deep dive into CSS variables implementation, see our dedicated guide. If you're using Tailwind CSS, check out design tokens with Tailwind for a plugin-based approach.

FAQ

Q: Should I use CSS variables or JavaScript objects for design tokens in React?

A: Use CSS variables for theming and runtime token switching, JavaScript objects for computed styles and programmatic color manipulation. A hybrid approach combining both gives maximum flexibility—CSS variables for most styling, JS objects when you need calculations or dynamic color generation.

Q: How do I add TypeScript types for my design tokens?

A: Define TypeScript interfaces that match your token structure, then use them to type your theme objects and Context providers. This provides autocomplete in VS Code and catches typos at compile time. Tools like Style Dictionary can auto-generate TypeScript types from JSON tokens.

Q: How do I implement dark mode with design tokens in React?

A: Create separate light and dark token sets, use ThemeProvider to manage active theme state, and apply tokens via CSS variables on a data attribute like [data-theme='dark']. The ThemeProvider updates the data attribute when users toggle themes, triggering automatic re-styling across all components.

Stop Extracting Design Values Manually

Upload a Figma screenshot and get JSONC tokens + a Claude-ready prompt in 30 seconds.

Cookie Preferences

We use cookies for analytics and advertising. Essential cookies are always enabled. Learn more

Design Tokens in React: Theme Provider Guide | HandoffPro