Skip to content

Theme System

A drop-in dark / light / system theme provider that detects OS preference, persists to localStorage, and injects CSS classes on <html>.

What Problem This Solves

You need theme switching that:

  • Respects the user's OS preference by default
  • Persists across sessions
  • Works with Tailwind's darkMode: 'class' strategy
  • Is framework-agnostic (just React + CSS classes)

Prerequisites

  • React 18+
  • Tailwind CSS with darkMode: 'class' in tailwind.config.js

Files

theme-provider.tsx

tsx
import { createContext, use, useEffect, useState } from "react";

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

type ThemeProviderProps = {
  children: React.ReactNode;
  defaultTheme?: Theme;
  storageKey?: string;
};

type ThemeProviderState = {
  theme: Theme;
  setTheme: (theme: Theme) => void;
};

const initialState: ThemeProviderState = {
  theme: "system",
  setTheme: () => null,
};

const ThemeProviderContext = createContext<ThemeProviderState>(initialState);

export function ThemeProvider({
  children,
  defaultTheme = "system",
  storageKey = "vite-ui-theme",
  ...props
}: ThemeProviderProps) {
  const [theme, setTheme] = useState<Theme>(
    () => (localStorage.getItem(storageKey) as Theme) || defaultTheme,
  );

  useEffect(() => {
    const root = window.document.documentElement;
    root.classList.remove("light", "dark");

    if (theme === "system") {
      const systemTheme = window.matchMedia("(prefers-color-scheme: dark)").matches
        ? "dark"
        : "light";
      root.classList.add(systemTheme);
      return;
    }

    root.classList.add(theme);
  }, [theme]);

  const value = {
    theme,
    setTheme: (theme: Theme) => {
      localStorage.setItem(storageKey, theme);
      setTheme(theme);
    },
  };

  return (
    <ThemeProviderContext.Provider {...props} value={value}>
      {children}
    </ThemeProviderContext.Provider>
  );
}

export const useTheme = () => {
  const context = use(ThemeProviderContext);
  if (context === undefined) {
    throw new Error("useTheme must be used within a ThemeProvider");
  }
  return context;
};

mode-toggle.tsx

tsx
import { Moon, Sun } from "lucide-react";
import { useTheme } from "./theme-provider";

export function ModeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <button
      onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
      className="inline-flex items-center justify-center rounded-md border p-2"
      aria-label="Toggle theme"
    >
      {theme === "dark" ? <Sun size={16} /> : <Moon size={16} />}
    </button>
  );
}

Usage

1. Wrap your app

tsx
// main.tsx
import { ThemeProvider } from "@/lib/components/theme-provider";

ReactDOM.createRoot(document.getElementById("root")!).render(
  <ThemeProvider defaultTheme="system" storageKey="myapp-theme">
    <App />
  </ThemeProvider>,
);

2. Use the hook or toggle anywhere

tsx
import { useTheme } from "@/lib/components/theme-provider";
import { ModeToggle } from "@/lib/components/mode-toggle";

export function Header() {
  const { theme } = useTheme();
  return (
    <header>
      <span>Current theme: {theme}</span>
      <ModeToggle />
    </header>
  );
}

3. Configure Tailwind

js
// tailwind.config.js
export default {
  darkMode: "class",
  // ...rest of config
};

Variations

  • React 18 (useContext): Replace use(ThemeProviderContext) with useContext(ThemeProviderContext).
  • Next.js: Wrap in a Client Component ('use client') and place in your root layout.
  • No Tailwind: Remove darkMode: 'class' and write your own CSS rules targeting .dark and .light classes on html.