The Theme-Switcher Context I Drop Into Every App

The drop-in `<ThemeProvider>` I keep in my dotfiles. CSS variables, `prefers-color-scheme` integration, and `localStorage` persistence in about 60 lines.

JavaScript
Frontend
3 snippets
react
hooks
css-variables
kiranpatel

By @kiranpatel

November 30, 2025

·

Updated May 18, 2026

654 views

10

4.3 (13)

// ThemeProvider: the bare bones I always start with. Holds a theme name in
// state, exposes { theme, setTheme, toggle }, and writes the active token map
// to documentElement as CSS variables so any component can `var(--bg)`.
const { useState, useEffect, useContext, useCallback, useMemo, createContext } = (typeof React !== 'undefined' ? React : {
    useState: (init) => {
        let v = typeof init === 'function' ? init() : init;
        return [v, (next) => { v = typeof next === 'function' ? next(v) : next; return v; }];
    },
    useEffect: (fn) => { const c = fn(); return c; },
    useContext: (ctx) => ctx._currentValue,
    useCallback: (f) => f,
    useMemo: (f) => f(),
    createContext: (def) => ({ _currentValue: def, Provider: ({ value, children }) => { ctx => ctx; return children; } }),
});

const THEMES = {
    light: { '--bg': '#ffffff', '--fg': '#111418', '--accent': '#2b6cb0' },
    dark: { '--bg': '#0b0b0c', '--fg': '#f5f5f7', '--accent': '#7aa9d8' },
};

const ThemeContext = createContext({ theme: 'light', setTheme: () => {}, toggle: () => {} });

function applyTokens(theme) {
    const tokens = THEMES[theme] || THEMES.light;
    const root = (typeof document !== 'undefined' && document.documentElement) || null;
    const setter = root && root.style && typeof root.style.setProperty === 'function' ? root.style.setProperty.bind(root.style) : null;
    if (!setter) {
        // Stand-in for the playground. In a real app the next three lines are the whole effect.
        console.log('would apply tokens for', theme + ':', tokens);
        return;
    }
    for (const key of Object.keys(tokens)) setter(key, tokens[key]);
}

function ThemeProvider(initialTheme) {
    const [theme, setTheme] = useState(initialTheme || 'light');
    useEffect(() => { applyTokens(theme); }, [theme]);
    const toggle = useCallback(() => setTheme((t) => (t === 'light' ? 'dark' : 'light')), []);
    const value = useMemo(() => ({ theme, setTheme, toggle }), [theme, toggle]);
    return value;
}

function useTheme() { return useContext(ThemeContext); }

// Drive it without a real renderer.
const api = ThemeProvider('light');
console.log('initial theme:', api.theme);
console.log('hook surface:', Object.keys(api).sort().join(', '));
api.toggle();
console.log('typeof useTheme:', typeof useTheme);

I keep the token table in a plain object because I want any colour change to be a one-line edit, not a styled-components migration. The useEffect writes the active set onto documentElement so children read var(--bg) directly without re-rendering on theme flips, which is the whole performance reason to do this with CSS variables instead of context-driven inline styles. Wrapping the value in useMemo keyed on theme is the same idea as the dedicated context-memoization snippet; without it every parent re-render would refresh consumers. The default fallback in createContext matters because it is what useTheme() returns when a consumer is rendered outside the provider, and silently broken theming is worse than a thrown error in development.