Skip to content
React rc styling 4 min read

Theming & Dark Mode

A good theming setup lets you change every color, shadow, and radius in your app by flipping a single value, without rewriting component styles. The modern approach combines two browser primitives—CSS custom properties for the actual design tokens and a data-theme attribute on the root element to switch between sets of values—with a thin layer of React for the toggle, persistence, and respecting the user’s system preference. Getting the details right means the theme is fast, accessible, and never flashes the wrong colors on load.

Design tokens as CSS variables

Rather than hard-coding #2563eb in dozens of components, define a vocabulary of semantic tokens—--color-bg, --color-text, --color-accent—once at the root. Components reference the token, and switching themes only rewrites the token values. Scope each theme to a [data-theme] selector so the active set is chosen by an attribute on <html>.

/* theme.css */
:root,
[data-theme="light"] {
  --color-bg: #ffffff;
  --color-surface: #f3f4f6;
  --color-text: #111827;
  --color-accent: #2563eb;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] {
  --color-bg: #0f172a;
  --color-surface: #1e293b;
  --color-text: #e2e8f0;
  --color-accent: #60a5fa;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.6);
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  transition: background 0.2s ease, color 0.2s ease;
}

Because custom properties cascade and inherit, any component that reads var(--color-bg) updates instantly when the attribute on <html> changes—no React re-render required for the visuals themselves.

A theme context and toggle

React’s job is to track which theme is active, persist the choice, and write it to the DOM. A single context exposes the current theme and a setter so any component can read or toggle it.

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

const ThemeContext = createContext(null);

function getInitialTheme() {
  const stored = localStorage.getItem('theme');
  if (stored === 'light' || stored === 'dark') return stored;
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  return prefersDark ? 'dark' : 'light';
}

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

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

  const toggleTheme = () =>
    setTheme((current) => (current === 'dark' ? 'light' : 'dark'));

  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;
}

The useEffect is the single point that syncs React state to the data-theme attribute and localStorage, so the two never drift apart. Consuming the context in a button gives you a working toggle.

import { useTheme } from './ThemeProvider';

export default function ThemeToggle() {
  const { theme, toggleTheme } = useTheme();
  return (
    <button onClick={toggleTheme} aria-label="Toggle color theme">
      {theme === 'dark' ? '☀️ Light' : '🌙 Dark'}
    </button>
  );
}

Respecting and tracking system preference

getInitialTheme reads prefers-color-scheme only when the user has not made an explicit choice. If you want the app to keep following the OS while no preference is stored, subscribe to changes on the media query.

useEffect(() => {
  const media = window.matchMedia('(prefers-color-scheme: dark)');
  const handleChange = (event) => {
    if (!localStorage.getItem('theme')) {
      setTheme(event.matches ? 'dark' : 'light');
    }
  };
  media.addEventListener('change', handleChange);
  return () => media.removeEventListener('change', handleChange);
}, []);

Tip: Add <meta name="color-scheme" content="light dark"> to your HTML so native UI—form controls, scrollbars, and the default page background—matches the active theme.

Avoiding the flash of wrong theme

If React decides the theme after hydration, the page paints in the default (usually light) theme for a frame before switching, producing a jarring flash. Prevent it by setting data-theme synchronously in an inline <script> in <head>, before any CSS or React loads.

<!-- index.html, inside <head> before stylesheets -->
<script>
  (function () {
    var stored = localStorage.getItem('theme');
    var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    var theme = stored || (prefersDark ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

This blocking script runs before first paint, so the correct tokens are in place immediately. React’s useEffect later reconciles its state with the attribute the script already set—they agree, so nothing visibly changes.

Strategy comparison

StrategyPersists choiceFollows OSFlash-freeNotes
CSS-only @media (prefers-color-scheme)NoYesYesNo manual toggle possible
data-theme + contextYesOptionalWith inline scriptRecommended default
Per-component useStateNoNoNoCauses re-renders, drift

Best Practices

  • Define semantic tokens (--color-text) rather than literal ones (--blue-600) so components stay theme-agnostic.
  • Switch themes with a single attribute on <html>, not by re-rendering styled components.
  • Initialize from localStorage, falling back to prefers-color-scheme, so first-time visitors get a sensible default.
  • Run a tiny inline script in <head> to set the theme before paint and eliminate the flash.
  • Verify both themes meet WCAG contrast ratios—dark mode often needs lighter accent colors.
  • Keep all DOM and storage side effects in one useEffect to avoid state and attribute drifting apart.
Last updated June 14, 2026
Was this helpful?