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.
Table of Contents
- 1. What a CSS variable theming system actually solves
- 2. Design tokens as CSS custom properties
- 3. A ThemeProvider without CSS in JS
- 4. Light dark mode with the data-theme attribute
- 5. Component level theming with scoped properties
- 6. TypeScript typing for theme tokens
- 7. Persistence and system preference
- 8. Accessibility: contrast and prefers-color-scheme
- 9. CSS variables compared to alternatives
- 10. Summary
- 11. FAQ
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.