Theming System with CSS Variables in React
AI generated
</>
{ }
React · CSS Custom Properties · Design Tokens
Theming System with CSS Variables in React
design tokens without CSS in JS overhead

A theming system with CSS variables stores design tokens as native custom properties in the browser instead of recomputing them via JavaScript on every render. The result is a theme switch without re rendering components, a smaller bundle size, and an approach that works independently of the chosen CSS strategy.

17 min read CSS Custom Properties · Design Tokens · Dark Mode React 19 · TypeScript

1. What a CSS variable theming system actually solves

A theming system with CSS variables stores colors, spacing, and typography values as native CSS custom properties instead of managing them in JavaScript objects and computing them as inline styles on every render. The decisive difference from classic CSS in JS is that a theme switch does not trigger a single React render: only an attribute on the root element, for example data-theme="dark", changes, and the browser applies the new variable values to all affected elements without the React component tree needing to be recomputed.

This approach solves a problem that many CSS in JS solutions historically had: distributing theme context via React context means that every component consuming that context re renders on a theme switch. In large applications with hundreds of themeable components, this adds up to noticeable delays when switching between light and dark. A theming system with CSS variables avoids this problem entirely, because the actual value change happens exclusively in the browser's CSS engine.

2. Design tokens as CSS custom properties

The first step in every theming system with CSS variables is defining design tokens as CSS custom properties at the root level. A design token is a named, technology agnostic value such as --color-primary or --spacing-md, defined in exactly one place and referenced everywhere in the stylesheet. Changing the brand color then only requires a single line change, instead of searching hundreds of places in the code.

Important for a robust theming system with CSS variables is a two tier token structure: primitive tokens such as --blue-500 define raw color values, semantic tokens such as --color-primary reference these primitive values with var(). This indirection allows swapping an entire color scheme by only remapping the semantic layer, while the primitive values stay unchanged and can be reused across multiple themes.


/* tokens.css — two-tier design token structure */
:root {
  /* Primitive tokens: raw color values */
  --blue-500: #0284c7;
  --blue-700: #075985;
  --slate-100: #f1f5f9;
  --slate-900: #0f172a;

  /* Semantic tokens: reference primitives, describe intent */
  --color-primary: var(--blue-500);
  --color-primary-hover: var(--blue-700);
  --color-surface: #ffffff;
  --color-text: var(--slate-900);
  --color-border: var(--slate-100);

  /* Spacing scale as tokens too */
  --spacing-sm: 0.5rem;
  --spacing-md: 1rem;
  --spacing-lg: 1.5rem;
}

3. A ThemeProvider without CSS in JS

A theming system with CSS variables does not need a context provider that passes color values through the component tree via props. Instead, a minimal component suffices that sets a data-theme attribute on the root element and keeps the current theme name in a very small context only for UI purposes, for example to display the current state in a toggle button. The actual visual change happens entirely outside of React, in the CSS stylesheet.

This ThemeProvider is deliberately kept lean: it exposes a theme variable and a setTheme function, but both are only needed for controls that display or change the theme name. All other components in the tree do not need to know about the theme context at all, because they source their colors exclusively via var(--color-primary) and similar CSS variables, which the browser resolves automatically.


// ThemeProvider.jsx — minimal, no color values pass through React at all
import { createContext, useContext, useEffect, useState } from "react";

const ThemeContext = createContext(null);

export function ThemeProvider({ children, defaultTheme = "light" }) {
  const [theme, setTheme] = useState(defaultTheme);

  useEffect(() => {
    // Only a single attribute changes — the browser applies new variables
    document.documentElement.setAttribute("data-theme", theme);
  }, [theme]);

  return (
    <ThemeContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useTheme() {
  const context = useContext(ThemeContext);
  if (!context) throw new Error("useTheme must be used within ThemeProvider");
  return context;
}

4. Light dark mode with the data-theme attribute

Implementing light and dark mode in a theming system with CSS variables does not require a second copy of all component styles. Instead, you define the same semantic token names twice: once under :root for the default case and once under an attribute selector such as [data-theme="dark"] with differing values. Every component that consistently uses semantic tokens instead of fixed color values automatically supports both modes, without its own code needing any changes.

This mechanism also scales to more than two themes: a third attribute such as [data-theme="high-contrast"] defines a third set of values for the same tokens. Since all components exclusively reference the semantic tokens and never the primitive values directly, adding a new theme is a pure CSS change without touching any React components.


/* themes.css — same token names, different values per data-theme */
:root {
  --color-surface: #ffffff;
  --color-text: #0f172a;
  --color-border: #e2e8f0;
}

[data-theme="dark"] {
  --color-surface: #0f172a;
  --color-text: #f1f5f9;
  --color-border: #334155;
}

/* Components never reference colors directly, only tokens */
.card {
  background: var(--color-surface);
  color: var(--color-text);
  border: 1px solid var(--color-border);
}

5. Component level theming with scoped properties

Beyond global themes, a theming system with CSS variables also allows locally scoped theming of individual component instances. Since CSS custom properties follow the normal CSS cascade and inheritance rules, a single component instance can override its own tokens by redefining them directly as an inline style or via an additional CSS class. All child components that reference the same token names automatically inherit the overridden value.

This pattern works excellently for variants such as a destructive button inside a form that locally needs a different primary color without affecting the global theme. Instead of writing a completely new button variant with its own CSS classes, it is enough to set --color-primary locally on the root container of that one instance. This flexibility makes a CSS variable based theming system significantly more granular than a single global theme object.


// DestructiveButton.jsx — component-level token override via inline style
function DestructiveButton({ children, ...rest }) {
  return (
    <div
      style={{
        // Only this subtree sees a different --color-primary value
        "--color-primary": "var(--red-600)",
        "--color-primary-hover": "var(--red-700)",
      }}
    >
      <button className="btn" {...rest}>
        {children}
      </button>
    </div>
  );
}

// The .btn class itself never changes — it always references
// var(--color-primary), the override happens purely through the cascade

6. TypeScript typing for theme tokens

Plain CSS custom properties offer no type safety out of the box, which can lead to typos in token names in a growing theming system with CSS variables that only surface at runtime in the browser. The solution is a small, generated TypeScript constant that lists all valid token names as a union type and provides a helper function token(name) that produces the ready made var(--name) string from it, rejecting invalid names at compile time.

This typing closes off a common source of errors: a typo such as --colr-primary instead of --color-primary would be silently ignored in plain CSS and simply have no effect. With a typed token() helper function, TypeScript reports this error immediately in the editor, long before the component is even rendered, which saves considerable debugging time especially in large theming systems with CSS variables with dozens of tokens.


// tokens.ts — typed helper against typos in token names
const designTokens = [
  "color-primary",
  "color-primary-hover",
  "color-surface",
  "color-text",
  "color-border",
  "spacing-sm",
  "spacing-md",
  "spacing-lg",
] as const;

type DesignToken = (typeof designTokens)[number];

// Compile-time checked — invalid names are rejected before render
function token(name: DesignToken): string {
  return `var(--${name})`;
}

// Usage in an inline style, still fully typed
// <div style={{ color: token("color-text") }}>

7. Persistence and system preference

A production ready theming system with CSS variables must persist the user's theme choice across sessions while also respecting the operating system preference when no explicit choice has been made. The usual priority: first look for a stored choice in localStorage, then fall back to window.matchMedia("(prefers-color-scheme: dark)"), and only as a last resort use a fixed default value.

A subtle but important point is avoiding a visible flicker on initial page load, known as a flash of unstyled theme. Since React only becomes active after hydration, the data-theme attribute should ideally already be set by a small inline script in the HTML head, even before React initializes at all. This way, the user immediately sees the correct theme without perceiving a brief jump from light to dark or vice versa.

8. Accessibility: contrast and prefers-color-scheme

Accessibility in a theming system with CSS variables is not purely an aesthetic question but a functional requirement. Every semantic color combination, for example text on background, must meet the WCAG contrast ratio guidelines in every defined theme, at least 4.5 to 1 for normal text. Since design tokens are centrally defined, contrast can be checked once per theme with automated tools instead of controlling every component individually.

In addition, a theming system with CSS variables should take the prefers-reduced-motion media query into account when theme switches are animated with transitions, and offer prefers-contrast: more as a possible fourth theme alongside light and dark. Users with visual impairments benefit significantly from a high contrast theme that uses the same token structure as all other themes but defines noticeably stronger contrast values.

9. CSS variables compared to alternatives

The following table compares CSS custom properties with the common alternatives for theming in React.

Approach Re render on theme switch Bundle overhead Component level theming
CSS custom properties None None, native browser feature Yes, via cascade and inheritance
CSS in JS with theme context Every consuming component Additional runtime library Yes, via props
Tailwind theme configuration None for pure class switching None at runtime, build time cost Limited without CSS variables

Tailwind CSS v4 itself internally uses CSS custom properties for its theme system, which shows how much this approach has become established. The combination of Tailwind utility classes and an underlying theming system with CSS variables is today the most pragmatic path, because both techniques use the same native browser feature and complement each other without any additional runtime library.

Mironsoft

React theming and design token architecture

Need a performant theming system for your React design system?

We build design token architectures with CSS custom properties, including dark mode, component level theming, and WCAG compliant contrast values in every theme.

Token architecture

Two tier design tokens for color, spacing, and typography

Dark mode implementation

Without re renders, with persistence and system preference

Contrast audit

WCAG contrast check for every defined theme

10. Summary

A theming system with CSS variables moves the actual color and value logic out of React into the browser's native CSS engine. Design tokens as two tier custom properties, a lean ThemeProvider without color values in context, and a simple data-theme attribute for switching between themes together form a system that works without re renders and can be used independently of the chosen CSS strategy.

Component level theming through the natural CSS cascade, a typed token() helper function against typos, and consistent consideration of persistence and accessibility turn a simple color switch into a complete theming system with CSS variables that does not lose performance as the number of components grows.

Theming System with CSS Variables — The Essentials

Core principle

Design tokens as CSS custom properties, theme switch changes only an attribute, no re render.

Token structure

Primitive tokens for raw values, semantic tokens for meaning, referenced with var().

Persistence

localStorage, then prefers-color-scheme, set the attribute via inline script before React hydration.

Accessibility

Check contrast per theme against WCAG, offer a high contrast theme as a fourth option.

11. FAQ: Theming System with CSS Variables in React

1Why CSS variables over CSS in JS?
CSS custom properties switch themes in the browser without a React re render.
2Primitive vs semantic tokens?
Primitives define raw values, semantics reference them and describe meaning.
3Does ThemeProvider need colors in context?
No, only the theme name for UI purposes, colors live in CSS.
4How does the mode switch work?
data-theme attribute on root, CSS selectors define differing values per mode.
5How to avoid load flicker?
Inline script in head sets data-theme before React hydration.
6Style components independently?
Yes, overridden tokens apply only within that component's cascade.
7Type safety for variable names?
Generated constant plus token helper function prevents silently ignored typos.
8Account for system preference?
matchMedia prefers-color-scheme as a fallback without a stored choice.
9Check contrast per theme?
Yes, central token definition allows a single automated WCAG contrast check per theme.
10Compatible with Tailwind?
Yes, Tailwind v4 uses CSS custom properties itself, both approaches complement each other.