Skip to main content
Technology & EngineeringDesign Systems185 lines

Theming

Theme systems, dark mode implementation, and runtime theme switching for design systems

Quick Summary22 lines
You are an expert in theme systems and dark mode for building and maintaining design systems.

## Key Points

1. **Surface colors** — backgrounds (surface, surface-raised, surface-overlay)
2. **Content colors** — text and icons (on-surface, on-primary, muted)
3. **Interactive colors** — actions and states (primary, destructive, focus-ring)
- Always define `color-scheme: light` or `color-scheme: dark` alongside your custom properties so native form controls, scrollbars, and system dialogs adapt automatically.
- Test every component in every theme — visual regression tools like Chromatic can render each story across themes in parallel.
- Provide a "system" option that follows the OS setting, and persist explicit user overrides in `localStorage`.
- Hard-coding color values in component CSS instead of referencing semantic tokens, which breaks the moment a second theme is introduced.
- Forgetting that shadows, borders, and images also need theme-aware treatment — dark mode is not just inverting text and backgrounds.

## Quick Example

```
Component code  -->  Semantic tokens  -->  Theme resolver  -->  Concrete values
     Button uses      color-primary         light theme          #3b82f6
     "color-primary"                        dark theme           #60a5fa
```
skilldb get design-systems-skills/ThemingFull skill: 185 lines
Paste into your CLAUDE.md or agent config

Theming — Design Systems

You are an expert in theme systems and dark mode for building and maintaining design systems.

Overview

Theming allows a design system to support multiple visual identities — light/dark modes, brand variations, or high-contrast accessibility themes — without changing component code. A well-designed theme layer swaps token values at the boundary while components reference semantic tokens that resolve differently per theme.

Core Philosophy

Theming is the mechanism that separates a design system's structure from its visual identity. Components define layout, interaction patterns, and accessibility behavior; the theme supplies the colors, typography, shadows, and spacing that give those components their look. This separation means the same component library can serve a light mode, a dark mode, a high-contrast accessibility mode, and multiple brand identities without any component code changes.

The key architectural insight is that components should never reference concrete color values — they reference semantic tokens that are resolved by the active theme. A button does not use #3b82f6; it uses color-primary, which the light theme resolves to #3b82f6 and the dark theme resolves to #60a5fa. This indirection is what makes theming possible and what makes it robust. Adding a new theme is a matter of defining a new token-to-value mapping, not modifying component source code.

Theme switching must be instantaneous, flash-free, and persistent. Users expect the theme they chose to be applied immediately on return visits without a flicker of the wrong theme. This means theme detection must happen synchronously in the document head before any CSS renders, the preference must be persisted (in localStorage or a cookie for SSR), and the transition between themes should use CSS custom properties that update in a single repaint rather than React re-renders that cascade through the tree.

Core Concepts

Theme Architecture

A theme is a complete mapping of semantic tokens to concrete values:

Component code  -->  Semantic tokens  -->  Theme resolver  -->  Concrete values
     Button uses      color-primary         light theme          #3b82f6
     "color-primary"                        dark theme           #60a5fa

Theme Delivery Mechanisms

MechanismHow it worksPros / Cons
CSS custom propertiesSwap variables on a parent selectorFast, no JS, cascading support
CSS class swap.theme-dark overrides variablesSimple, SSR-friendly
JS theme contextReact context provides token objectType-safe, dynamic, bundle cost
color-schemeNative browser hint for form controlsFree OS integration
Media queryprefers-color-scheme detectionZero JS, respects user setting

Color Palette Strategy

Design at least three semantic layers:

  1. Surface colors — backgrounds (surface, surface-raised, surface-overlay)
  2. Content colors — text and icons (on-surface, on-primary, muted)
  3. Interactive colors — actions and states (primary, destructive, focus-ring)

Implementation Patterns

CSS Custom Properties Theme

/* tokens-light.css */
:root,
[data-theme="light"] {
  color-scheme: light;
  --color-surface: #ffffff;
  --color-surface-raised: #f9fafb;
  --color-on-surface: #111827;
  --color-on-surface-muted: #6b7280;
  --color-primary: #3b82f6;
  --color-on-primary: #ffffff;
  --color-border: #e5e7eb;
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.1);
}

/* tokens-dark.css */
[data-theme="dark"] {
  color-scheme: dark;
  --color-surface: #111827;
  --color-surface-raised: #1f2937;
  --color-on-surface: #f9fafb;
  --color-on-surface-muted: #9ca3af;
  --color-primary: #60a5fa;
  --color-on-primary: #1e3a5f;
  --color-border: #374151;
  --shadow-md: 0 4px 6px -1px rgb(0 0 0 / 0.4);
}

System Preference Detection with Override

type Theme = 'light' | 'dark' | 'system';

function resolveTheme(preference: Theme): 'light' | 'dark' {
  if (preference !== 'system') return preference;
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function applyTheme(preference: Theme) {
  const resolved = resolveTheme(preference);
  document.documentElement.setAttribute('data-theme', resolved);
  localStorage.setItem('theme-preference', preference);
}

// Listen for OS-level changes when set to "system"
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', () => {
    const saved = (localStorage.getItem('theme-preference') ?? 'system') as Theme;
    if (saved === 'system') applyTheme('system');
  });

Preventing Flash of Incorrect Theme (FOIT)

Inject a blocking script in the <head> before any CSS loads:

<script>
  (function() {
    var pref = localStorage.getItem('theme-preference') || 'system';
    var resolved = pref;
    if (pref === 'system') {
      resolved = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    }
    document.documentElement.setAttribute('data-theme', resolved);
  })();
</script>

React Theme Provider

import { createContext, useContext, useEffect, useState, ReactNode } from 'react';

type Theme = 'light' | 'dark' | 'system';

const ThemeContext = createContext<{
  theme: Theme;
  setTheme: (t: Theme) => void;
}>({ theme: 'system', setTheme: () => {} });

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem('theme-preference') as Theme) ?? 'system'
  );

  useEffect(() => {
    const resolved =
      theme === 'system'
        ? window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
        : theme;
    document.documentElement.setAttribute('data-theme', resolved);
    localStorage.setItem('theme-preference', theme);
  }, [theme]);

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

export const useTheme = () => useContext(ThemeContext);

Best Practices

  • Always define color-scheme: light or color-scheme: dark alongside your custom properties so native form controls, scrollbars, and system dialogs adapt automatically.
  • Test every component in every theme — visual regression tools like Chromatic can render each story across themes in parallel.
  • Provide a "system" option that follows the OS setting, and persist explicit user overrides in localStorage.

Common Pitfalls

  • Hard-coding color values in component CSS instead of referencing semantic tokens, which breaks the moment a second theme is introduced.
  • Forgetting that shadows, borders, and images also need theme-aware treatment — dark mode is not just inverting text and backgrounds.

Anti-Patterns

  • Hardcoded colors in component code. Using raw hex values like #3b82f6 or Tailwind utilities like bg-blue-600 directly in components instead of semantic tokens makes theming impossible. Every color reference should go through the token layer.

  • Theme as an afterthought. Building an entire component library with a single light theme and then trying to add dark mode is an order of magnitude harder than designing both themes from the start. Semantic token architecture should be established before the first component is built.

  • Only theming text and backgrounds. Dark mode requires attention to shadows (which need to be stronger on dark surfaces to remain visible), borders (which need lighter values), images (which may need dimming or alternative assets), and form controls (which need color-scheme to adapt native UI).

  • Flash of incorrect theme (FOIT). Applying the theme class via JavaScript that runs after the initial paint produces a visible flash of the default theme. The theme must be resolved in a synchronous <script> block in the <head>, before any CSS or body content loads.

  • No "system" option in the theme picker. Forcing users to choose between light and dark without a "follow system preference" option ignores users whose OS switches themes automatically (e.g., based on time of day). Always offer light, dark, and system as three distinct choices.

Install this skill directly: skilldb add design-systems-skills

Get CLI access →