Dark Mode Done Properly
- react
- nextjs
- css
- typescript
- frontend
- design-systems
- accessibility
- ux
- javascript
- web-development
In Design Tokens and Theming, I covered how to structure color, spacing, and typography as tokens instead of scattering hex values through components. Dark mode is the feature that actually tests whether that token system holds up. If your tokens are well-formed, dark mode is a few dozen lines of CSS and a small React hook. If they aren't, dark mode turns into a multi-day slog of hunting down hardcoded colors component by component.
This is part of the React & Next.js Complete Guide series, which builds up from React Fundamentals for Professionals toward production Next.js apps. I want to walk through the parts of dark mode that are genuinely tricky: avoiding the flash of the wrong theme on load, picking a persistence strategy that survives SSR, and handling the pitfalls that only show up once real users start switching themes on real devices.
Most dark mode tutorials stop at "add a class to the body and swap some CSS variables." That part is easy. The hard part is making the switch instant, consistent across server and client render, and free of the flicker that makes a theme toggle feel broken even when the colors themselves are correct.
Why dark mode is fundamentally a rendering problem
The naive approach to dark mode is a useState boolean, a useEffect that reads localStorage, and a class toggle on mount. This works in a client-only app. In a Next.js app with server rendering, it produces a specific and very visible bug: the server has no idea what theme the user prefers. It renders the light theme by default, ships that HTML to the browser, and then a useEffect fires after hydration to flip the class to dark. For a fraction of a second, every visitor with dark mode enabled sees a bright white flash before the correct theme kicks in.
This happens because theme preference lives in a place the server can't see during the initial render: localStorage, or a media query evaluated client-side. More state in React won't fix that. What you need is sequencing: get the correct class onto the html or body element before React hydrates, ideally before the browser paints anything at all.
The standard fix is a small, synchronous inline script placed in the document head, before any stylesheet or component renders:
<script>
(function () {
try {
var stored = localStorage.getItem("theme");
var theme =
stored === "dark" || stored === "light"
? stored
: window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
document.documentElement.setAttribute("data-theme", theme);
} catch (e) {
document.documentElement.setAttribute("data-theme", "light");
}
})();
</script>
This script runs before hydration and before paint, so there's nothing to flash. It reads a stored preference if one exists, falls back to the OS-level prefers-color-scheme media query, and sets a data-theme attribute directly on <html>. CSS variables scoped to [data-theme="dark"] then apply immediately, with zero JavaScript framework involvement.
In the Next.js App Router, this goes in app/layout.tsx, injected as a raw script tag rather than a component that React manages, precisely because it needs to run outside React's normal lifecycle:
// app/layout.tsx
const themeInitScript = `
(function () {
try {
var stored = localStorage.getItem('theme');
var theme = stored === 'dark' || stored === 'light'
? stored
: window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
} catch (e) {
document.documentElement.setAttribute('data-theme', 'light');
}
})();
`;
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
</head>
<body>{children}</body>
</html>
);
}
The suppressHydrationWarning on <html> matters here. React compares server-rendered HTML against the client's first render, and the data-theme attribute set by the inline script will differ from whatever the server assumed, since the server has no access to localStorage or the OS theme preference. Without suppressing it, React logs a hydration mismatch warning on every page load, even though nothing is actually broken. Scope the suppression to just this element rather than reaching for it globally, since it silences a warning that's genuinely useful everywhere else.
Building the theme toggle with CSS variables and tokens
Once the initial theme is set correctly, the toggle itself is straightforward, assuming your design tokens are structured to support it. The pattern from the previous article carries over directly: define semantic tokens as CSS custom properties, then define two value sets, one per data-theme attribute.
/* globals.css */
:root,
[data-theme="light"] {
--color-background: #ffffff;
--color-surface: #f4f4f5;
--color-text-primary: #18181b;
--color-text-secondary: #52525b;
--color-border: #e4e4e7;
--color-accent: #2563eb;
}
[data-theme="dark"] {
--color-background: #18181b;
--color-surface: #27272a;
--color-text-primary: #fafafa;
--color-text-secondary: #a1a1aa;
--color-border: #3f3f46;
--color-accent: #60a5fa;
}
body {
background-color: var(--color-background);
color: var(--color-text-primary);
}
The key discipline is the same one that makes the token system valuable in the first place: components reference --color-surface or --color-border, never a raw hex value. If a component hardcodes #f4f4f5 because it happened to match the light background at the time, that component will silently break in dark mode. It won't show up until someone actually toggles the theme and looks at that specific screen.
The React side is a thin hook that reads and writes the data-theme attribute and mirrors the choice to localStorage:
// hooks/use-theme.ts
"use client";
import { useCallback, useEffect, useState } from "react";
type Theme = "light" | "dark";
export function useTheme() {
const [theme, setThemeState] = useState<Theme>(() => {
if (typeof document === "undefined") return "light";
const current = document.documentElement.getAttribute("data-theme");
return current === "dark" ? "dark" : "light";
});
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
localStorage.setItem("theme", theme);
}, [theme]);
const toggleTheme = useCallback(() => {
setThemeState((prev) => (prev === "dark" ? "light" : "dark"));
}, []);
return { theme, setTheme: setThemeState, toggleTheme };
}
Notice the initial state reads directly from the DOM attribute rather than from localStorage again. By the time this hook runs, the inline script has already set data-theme correctly. Reading it back keeps the hook's state and the actual rendered theme in sync from the first render, with no second flash caused by the hook re-deciding the theme independently.
Respecting the system preference without fighting it
A theme toggle usually needs three states in the underlying model, even if the UI only exposes two buttons: light, dark, and system. "System" is a real preference in its own right, for users who want their app to follow whatever their OS is doing, including switching automatically at sunset if that's how their OS is configured.
// hooks/use-theme.ts (extended)
type ThemePreference = "light" | "dark" | "system";
function resolveTheme(preference: ThemePreference): "light" | "dark" {
if (preference !== "system") return preference;
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function useThemePreference() {
const [preference, setPreference] = useState<ThemePreference>(() => {
if (typeof localStorage === "undefined") return "system";
const stored = localStorage.getItem("theme-preference");
return stored === "light" || stored === "dark" || stored === "system" ? stored : "system";
});
useEffect(() => {
const applied = resolveTheme(preference);
document.documentElement.setAttribute("data-theme", applied);
localStorage.setItem("theme-preference", preference);
if (preference !== "system") return;
const media = window.matchMedia("(prefers-color-scheme: dark)");
const listener = () => {
document.documentElement.setAttribute("data-theme", resolveTheme("system"));
};
media.addEventListener("change", listener);
return () => media.removeEventListener("change", listener);
}, [preference]);
return { preference, setPreference };
}
The matchMedia change listener is the detail that's easy to skip. Without it, a user who picked "system" and then changes their OS theme at the OS level won't see your app update until they reload the page. That inconsistency is subtle enough that it rarely gets caught in a quick manual test, but it's exactly the kind of thing that makes an app feel like it wasn't built with real attention to the platform.
Production pitfalls worth planning for
A handful of issues show up specifically once dark mode ships to real traffic rather than a local dev environment.
Third-party embeds and iframes don't inherit your CSS variables. Maps, payment widgets, and analytics dashboards embedded via iframe render in their own document context. If they support a dark mode of their own, it's usually through a separate configuration option or query parameter, not through inheriting your page's styles. Check each embed's own theming API rather than assuming it will "just work."
Images and illustrations often need theme-aware variants. A screenshot with a white background, a logo with dark text, or a diagram optimized for light backgrounds all look wrong once the surrounding UI goes dark. Where this matters for the reader's actual experience, plan for a light and dark asset pair, swapped through the same data-theme attribute using CSS ([data-theme="dark"] .logo { content: url(...) } or a conditional picture element), rather than trying to patch it after launch.
Contrast ratios need re-checking in dark mode, not assumed from the light palette. A color pairing that passes WCAG AA contrast in light mode doesn't automatically pass in dark mode; the relationship between foreground and background luminance changes. Muted grays that read fine as secondary text on white can become nearly invisible on a dark surface. Run the actual dark palette through a contrast checker rather than eyeballing it.
Email templates and PDFs are usually out of scope, and that's fine. Transactional emails render inside the recipient's mail client, which has its own dark mode handling (or none at all), and most email clients strip or ignore CSS custom properties entirely. Don't try to extend the same token system there; treat email and print output as a separate, simpler design surface.
Key takeaways
- Dark mode is primarily a sequencing problem: get the correct theme attribute set before the browser paints, using a synchronous inline script, not a post-hydration `useEffect`.
- Scope `suppressHydrationWarning` narrowly, to the single element whose attribute the script sets, so you don't silence legitimate hydration warnings elsewhere.
- A well-structured token system, one CSS variable per semantic color, referenced everywhere instead of raw hex values, is what makes adding dark mode later cheap instead of a full component audit.
- Model theme preference as light, dark, or system, and actually listen for OS-level `prefers-color-scheme` changes when the user has chosen "system."
- Re-check contrast ratios against the dark palette specifically; passing WCAG in light mode says nothing about the dark variant.
- Third-party embeds, illustrations, and transactional emails typically sit outside your token system and need their own theming plan, if they need one at all.
Frequently asked questions
Do I need a database column for theme preference, or is localStorage enough?
For most SaaS products, `localStorage` alone is enough, since theme preference is a device-level UI setting, not business data. If you want the preference to follow a logged-in user across devices, store it on the user profile and sync it on login, falling back to `localStorage` for anonymous visitors and as an immediate local cache even for logged-in users.
Why does my dark mode toggle still flash on the first load in production but not in dev?
This usually means the inline script is being deferred, minified in a way that breaks it, or placed after other render-blocking resources instead of at the very top of `<head>`. Check the built HTML output directly rather than trusting that it behaves the same as the dev server; some frameworks reorder scripts during the production build in ways that push it later than expected.
Should I use Tailwind's `dark:` variant or CSS custom properties for theming?
They aren't mutually exclusive. Tailwind's class-based dark mode (`darkMode: 'class'`) toggles based on a class on `html`, which pairs well with the same `data-theme` attribute approach if you also set the class. CSS variables are still worth defining underneath, especially for values used outside Tailwind's utility classes, like chart colors or values passed into third-party components that don't understand Tailwind classes.
How do I test dark mode without manually toggling it on every page?
Storybook's theme-switching addons, or a simple test harness that renders your key components under both `data-theme="light"` and `data-theme="dark"`, catch hardcoded colors early. Visual regression tools that snapshot both themes are worth the setup once you have more than a handful of components, since manual review doesn't scale past that point.
Does dark mode affect SEO or Core Web Vitals?
Not directly, as long as the inline theme script is small and synchronous rather than blocking on a network request. A poorly implemented flash-of-wrong-theme doesn't hurt crawlability, but it does hurt perceived performance for real users, which is worth fixing regardless of any direct SEO signal.
What's the right default when the user has no stored preference and their browser doesn't report `prefers-color-scheme`?
Fall back to light. Browsers that don't support the media query return `matches: false` for both `(prefers-color-scheme: dark)` and `(prefers-color-scheme: light)`, so treating an unmatched dark query as "not dark" and defaulting to light is the safer assumption for the widest range of devices and older browsers.
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.