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);
}
};
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)",
}
}
};
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);
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>
);
}
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;
}
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]);
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>
);
}
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;
}
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]);
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]);
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;
}
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();
});
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)