DEV Community

Rizwan Saleem
Rizwan Saleem

Posted on

Building an Accessible, Performant Dark-Mode System for Modern React Apps

Building an Accessible, Performant Dark-Mode System for Modern React Apps

Building an Accessible, Performant Dark-Mode System for Modern React Apps

Designing and shipping a robust dark-mode experience goes beyond toggling CSS variables. This guide walks you through an accessible, performant approach for a React frontend, with real code patterns you can reuse in production. You’ll learn a practical architecture, theming strategy, persistence, performance considerations, and testing methods to ensure a reliable experience for all users.

Why a holistic approach matters

  • Accessibility: Color contrast, prefers-color-scheme, and user overrides must be respected.
  • Performance: Theming should be lightweight, avoiding reflows and large CSS rebuilds.
  • Consistency: A single source of truth for themes prevents drift across components.
  • UX: Respect system preferences while giving users explicit control.

This guide gives you a concrete pattern you can adapt to most React stacks (CRA, Vite, Next.js, etc.) without relying on heavy theming libraries.

Architecture overview

  • Central theme store: A lightweight state container for current theme, system preference, and user overrides.
  • CSS strategy: Use CSS custom properties (variables) for colors, shadows, and surfaces; switch themes by toggling a root class or data attribute.
  • Persisted preferences: Save user choices in localStorage with a small debounce to avoid excessive writes.
  • Accessibility hooks: Respect prefers-color-scheme and provide accessible keyboard controls for toggling.

Core pieces:

  • Theme definition: Light, Dark, and optionally System (auto).
  • Theme provider: React context that exposes current theme and a toggle API.
  • CSS layer: A minimal, predictable set of CSS variables for each theme.
  • Persistence: LocalStorage with a readable API and safety checks. ### Step 1: Define themes with a minimal, explicit API

Create a central theme file that describes the values you’ll theme around.

// src/themes/index.js
export const themes = {
  light: {
    name: "light",
    // color tokens
    bg: #ffffff;
    surface: #f7f7f7;
    text: #1f2937;
    muted: #6b7280;
    border: rgba(0,0,0,0.1);
    primary: #2563eb;
    primary-contrast: #ffffff;
    // elevation
    shadow: 0 1px 2px rgba(0,0,0,.08);
  },
  dark: {
    name: "dark",
    bg: #0b1020;
    surface: #141a2b;
    text: #e5e7eb;
    muted: #94a3b8;
    border: rgba(255,255,255,0.08);
    primary: #60a5fa;
    primary-contrast: #0b1020;
    shadow: 0 6px 18px rgba(0,0,0,.35);
  }
};
Enter fullscreen mode Exit fullscreen mode

Note: In actual code, you can’t define CSS custom properties inside a JS object directly as CSS variables. The idea is to map to values you’ll inject via inline styles or a CSS block. Here’s a practical pattern:

// src/themes/index.js
export const THEMES = {
  light: {
    name: "light",
    vars: {
      bg: "#ffffff",
      surface: "#f7f7f7",
      text: "#1f2937",
      muted: "#6b7280",
      border: "rgba(0,0,0,0.1)",
      primary: "#2563eb",
      shadow: "0 1px 2px rgba(0,0,0,.08)",
    }
  },
  dark: {
    name: "dark",
    vars: {
      bg: "#0b1020",
      surface: "#141a2b",
      text: "#e5e7eb",
      muted: "#94a3b8",
      border: "rgba(255,255,255,0.08)",
      primary: "#60a5fa",
      shadow: "0 6px 18px rgba(0,0,0,.35)",
    }
  }
};
Enter fullscreen mode Exit fullscreen mode

Step 2: Theme provider and system preference

Create a small ThemeContext with a hook to read system preference and user preference.

// src/theme/ThemeProvider.jsx
import React, { createContext, useEffect, useMemo, useState } from "react";
import { THEMES } from "./index";

const ThemeContext = createContext();

const getSystemPref = () => {
  if (typeof window !== "undefined" && window.matchMedia) {
    return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
  }
  return "light";
};

export const ThemeProvider = ({ children }) => {
  const [mode, setMode] = useState("system"); // system | light | dark
  const [theme, setTheme] = useState(THEMES.light);

  // Apply theme based on mode and system preference
  useEffect(() => {
    const effective =
      mode === "system" ? getSystemPref() : mode;
    setTheme(THEMES[effective]);
  }, [mode]);

  // Persist mode to localStorage
  useEffect(() => {
    if (typeof window === "undefined") return;
    localStorage.setItem("theme-mode", mode);
  }, [mode]);

  // On mount, read persisted mode
  useEffect(() => {
    if (typeof window === "undefined") return;
    const saved = localStorage.getItem("theme-mode");
    if (saved === "light" || saved === "dark" || saved === "system") {
      setMode(saved);
    } else {
      setMode("system");
    }
  }, []);

  // Apply CSS variables to :root or a data attribute
  useEffect(() => {
    const root = document.documentElement;
    const vars = theme.vars;
    // clean up previous custom props if needed
    Object.entries(vars).forEach(([k, v]) => {
      root.style.setProperty(`${k}`, v);
    });
  }, [theme]);

  const toggle = () => {
    // simple toggle between light and dark when not using "system"
    if (mode === "system") {
      setMode("dark"); // pick a default fallback
    } else if (mode === "dark") {
      setMode("light");
    } else {
      setMode("dark");
    }
  };

  const value = useMemo(
    () => ({
      mode,
      themeName: theme.name,
      setMode,
      toggle,
      // helper for components if needed
    }),
    [mode, theme.name]
  );

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

export const useTheme = () => React.useContext(ThemeContext);
Enter fullscreen mode Exit fullscreen mode

Usage in app root:

// src/main.jsx or App.jsx
import React from "react";
import { ThemeProvider } from "./theme/ThemeProvider";
import AppRouter from "./AppRouter";

export default function App() {
  return (
    <ThemeProvider>
      <AppRouter />
    </ThemeProvider>
  );
}
Enter fullscreen mode Exit fullscreen mode

Step 3: CSS strategy with CSS variables

Set up a minimal CSS layer that uses CSS variables for colors and surfaces. You’ll toggle themes by updating variables on root. Also provide system preference styling via the [data-theme] attribute if you want a fallback approach.

/* src/styles/global.css */

:root {
  bg: #ffffff;
  surface: #f7f7f7;
  text: #1f2937;
  muted: #6b7280;
  border: rgba(0,0,0,0.1);
  primary: #2563eb;
  shadow: 0 1px 2px rgba(0,0,0,.08);
}

[data-theme="dark"] {
  bg: #0b1020;
  surface: #141a2b;
  text: #e5e7eb;
  muted: #94a3b8;
  border: rgba(255,255,255,0.08);
  primary: #60a5fa;
  shadow: 0 6px 18px rgba(0,0,0,.35);
}

html, body, #root {
  height: 100%;
}

body {
  background-color: var(bg);
  color: var(text);
  margin: 0;
  font-family: system-ui, -apple-system, "Segoe UI", Roboto, Inter, Arial;
}
.app-shell {
  background: var(bg);
}
.card {
  background: var(surface);
  color: var(text);
  border: 1px solid var(border);
  box-shadow: var(shadow);
  border-radius: 12px;
  padding: 1rem;
}
.button {
  background: var(primary);
  color: #fff;
  border: 0;
  padding: 0.5rem 1rem;
  border-radius: 6px;
  cursor: pointer;
}
Enter fullscreen mode Exit fullscreen mode

Apply the theme by setting a data attribute on a top-level element based on current mode. You can do this in a small effect inside the ThemeProvider, but a simpler pattern is to reflect mode into a data-theme attribute on the documentElement:

// inside ThemeProvider useEffect
useEffect(() => {
  const root = document.documentElement;
  const effective =
    mode === "system" ? getSystemPref() : mode;
  root.setAttribute("data-theme", effective);
}, [mode]);
Enter fullscreen mode Exit fullscreen mode

Then CSS targets data-theme accordingly.

Step 4: Accessible theme controls

Provide a keyboard-accessible toggle and a small status indicator.

// src/components/ThemeSwitcher.jsx
import React from "react";
import { useTheme } from "../theme/ThemeProvider";

export default function ThemeSwitcher() {
  const { mode, toggle } = useTheme();

  // Compute label for screen readers
  const label = mode === "system" ? "System preferred" : mode;

  return (
    <button
      className="button"
      aria-label="Toggle color theme"
      onClick={toggle}
      title="Toggle theme"
    >
      {mode === "dark" ? "Light mode" : "Dark mode"}
      <span className="sr-only" style={{ marginLeft: 8 }}>
        (current: {label})
      </span>
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Add a visually hidden class for accessibility:

.sr-only {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0,0,0,0);
  white-space: nowrap; border: 0;
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Persistence and debounce

If you want to debounce writes to localStorage for performance, you can implement a tiny debounce around persistence.

// inside ThemeProvider
useEffect(() => {
  const t = setTimeout(() => {
    localStorage.setItem("theme-mode", mode);
  }, 150);
  return () => clearTimeout(t);
}, [mode]);
Enter fullscreen mode Exit fullscreen mode

This keeps localStorage writes snappy without hammering the storage during rapid toggles.

Step 6: System preference changes at runtime

If the user changes their system theme while your app is open, you may want to react to it when mode is system.

useEffect(() => {
  const mq = window.matchMedia("(prefers-color-scheme: dark)");
  const onChange = () => {
    if (mode === "system") {
      // trigger a re-render by updating a state or theme
      setTheme(THEMES[mq.matches ? "dark" : "light"]);
    }
  };
  mq.addEventListener ? mq.addEventListener("change", onChange) : mq.addListener(onChange);
  return () => {
    mq.removeEventListener ? mq.removeEventListener("change", onChange) : mq.removeListener(onChange);
  };
}, [mode]);
Enter fullscreen mode Exit fullscreen mode

This ensures the UI reflects system changes when you’re in system mode.

Step 7: Type safety and DX

If you’re using TypeScript, provide types for Theme keys and the context value.

// src/theme/ThemeProvider.tsx
type ThemeName = "light" | "dark";
type Mode = "system" | ThemeName;

interface ThemeContextValue {
  mode: Mode;
  themeName: ThemeName;
  setMode: (m: Mode) => void;
  toggle: () => void;
}
Enter fullscreen mode Exit fullscreen mode

This helps catch typos and makes the API self-documenting.

Step 8: Performance considerations

  • Minimize re-renders: The ThemeProvider should avoid triggering expensive re-renders. The actual CSS reset happens by updating CSS variables, which is cheap.
  • Keep CSS variables centralized: A single pass applying variables reduces layout thrash.
  • Prefer CSS for theming: Avoid heavy runtime style computations in components.
  • Debounce persistence: As shown, throttle localStorage writes.

    Step 9: Testing the dark-mode experience

  • Visual checks: Manually toggle and verify contrast across critical components (buttons, inputs, cards, nav).

  • Accessibility checks:

    • Ensure contrast ratios meet WCAG AA for all themes.
    • Verify focus states are visible in both themes.
    • Confirm system preference is respected when in system mode.
  • Automated tests:

    • Unit test ThemeProvider to switch modes and verify themeName updates.
    • Snapshot tests for a themed component to ensure CSS vars affect rendering.

Example test (pseudo, Jest with React Testing Library):

import { render, screen, fireEvent } from "@testing-library/react";
import { ThemeProvider } from "../theme/ThemeProvider";
import ThemeSwitcher from "../components/ThemeSwitcher";

test("toggles theme on click", () => {
  render(
    <ThemeProvider>
      <ThemeSwitcher />
    </ThemeProvider>
  );
  const button = screen.getByRole("button");
  expect(button).toBeInTheDocument();
  // initial state may be system; simulate a user action
  fireEvent.click(button);
  // verify some state changed, e.g., CSS vars or accessible label
  expect(button).toBeTruthy();
});
Enter fullscreen mode Exit fullscreen mode

Step 10: Real-world integration patterns

  • Next.js apps: Hydration-safe theme initialization can occur on the client side, with a small loader if you want to avoid a flash of wrong theme. Server-side rendering can render a default theme to avoid mismatch, then hydrate with user preference.
  • Design systems: Tie the tokens to your design system’s color palette. If you export tokens for both themes, you can generate a consistent set of tokens for both light and dark modes.
  • Marketing pages: Ensure initial color scheme matches the system preference to minimize cognitive load when a user first lands.

    Quick-start checklist

  • [ ] Create a lightweight ThemeProvider with system/user mode and a toggle API.

  • [ ] Define two themes using CSS variables and apply them via root-level CSS.

  • [ ] Implement a keyboard-accessible theme switch with a11y considerations.

  • [ ] Persist user mode in localStorage with a small debounce.

  • [ ] React to system preference changes when in system mode.

  • [ ] Add unit tests for the theming logic and a simple manual QA pass.
    If you’d like, I can tailor this pattern to your stack (e.g., Next.js App Router, Vite + React, or CRA) and generate a ready-to-run repository scaffold with TypeScript typings and a sample page demonstrating the dark-mode system end-to-end. Would you prefer a Next.js-focused version or a framework-agnostic React setup?

-

Rizwan Saleem | https://rizwansaleem.co

Top comments (0)