How to Implement a Dark/Light Theme with Persistence and Scalability

Rohit Sonar Blogs
Rohit Sonar
Cover Image of the article

You don’t want “just a toggle.” You want a theme system that:

  • remembers the user’s choice,
  • avoids the flash of the wrong theme,
  • scales to more themes (and brands) later,
  • and stays accessible.

Here’s a battle-tested approach that balances UX, performance, and future growth.

1) Start with Design Tokens (not colors)

Hard-coding hex values in components won’t scale. Define semantic tokens (e.g., --color-bg, --color-text, --shadow-elev-1) and map them to physical values per theme.

/* Base tokens (defaults) */
:root {
--color-bg: #ffffff;
--color-text: #111111;
--surface: #f6f6f6;
--link: #0b5fff;
}

/* Dark tokens */
:root[data-theme="dark"] {
--color-bg: #0c0c0c;
--color-text: #f5f5f5;
--surface: #171717;
--link: #6ea8ff;
}

/* Use tokens everywhere */
body { background: var(--color-bg); color: var(--color-text); }
a { color: var(--link); }

Why this matters: Semantic tokens make it trivial to add more themes (e.g., “dim,” “high-contrast,” “brand-x”) and to theme entire component libraries without touching component logic.

2) Respect the user’s system preference first

Most users prefer sites to follow their OS setting initially.

@media (prefers-color-scheme: dark) {
:root { color-scheme: dark; }
}
@media (prefers-color-scheme: light) {
:root { color-scheme: light; }
}

color-scheme also helps the browser render form controls and scrollbars that match the theme.

3) Persistence strategy: choose the right store

You need the theme before the app paints to avoid the “flash.”

Options:

  • localStorage (client-only): simple, but suffers a brief flash on first paint.
  • Cookie (SSR-friendly): read theme on the server, render the right CSS from the first byte (best for Next.js/Nuxt).
  • Database (per user): for signed-in users, store preference server-side and mirror to a cookie for SSR.

Recommended hybrid:

  • On toggle, write cookie (for SSR) and localStorage (for quick client reads).
  • For authenticated users, sync to the profile table too.

4) Prevent the flash of incorrect theme (FOIT/FOUC)

Inline a tiny script high in <head> that sets the data-theme attribute before CSS loads.

<script>
(function () {
try {
const cookie = document.cookie.match(/(?:^| )theme=([^;]+)/);
const stored = window.localStorage.getItem('theme');
const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
const theme = (cookie && cookie[1]) || stored || systemDark;
document.documentElement.setAttribute('data-theme', theme);
document.documentElement.style.colorScheme = theme;
} catch (e) {}
})();
</script>

Place this before your CSS bundle so the initial paint is correct.

5) The toggle: simple, accessible, and reactive

  • A button with aria-pressed and a clear label.
  • Update data-theme, write cookie + localStorage, and optionally emit an event so components can react.

function setTheme(next) {
document.documentElement.setAttribute('data-theme', next);
document.documentElement.style.colorScheme = next;
localStorage.setItem('theme', next);
document.cookie = `theme=${next}; Path=/; Max-Age=31536000; SameSite=Lax`;
}

Consider a “System” option that tracks OS changes dynamically:

const mql = window.matchMedia('(prefers-color-scheme: dark)');
mql.addEventListener('change', e => {
const pref = localStorage.getItem('theme');
if (pref === 'system' || !pref) {
setTheme(e.matches ? 'dark' : 'light');
}
})
;

6) SSR with Next.js (no flash, correct HTML)

On the server, read the cookie and set data-theme in the initial HTML. In the App Router, this typically lives in layout.tsx (reading headers via cookies()), or via middleware to inject a default.

Minimal flow:

  1. Read theme cookie in server component.
  2. Add data-theme={theme} to <html>.
  3. Inline the head script above to reconcile on hydration.

7) Tailwind or component libraries

  • Tailwind: use the class strategy or a data-theme selector.
    • In tailwind.config.js, set darkMode: ['class', '[data-theme="dark"]'].
    • Use utilities that refer to CSS variables: bg-[var(--color-bg)].
  • Third-party components: theme them by overriding their CSS variables or exposing a design-token layer for them.

8) Accessibility first

  • Aim for WCAG AA contrast (at least 4.5:1 for text). Test both themes.
  • Ensure focus states are visible in both dark and light.
  • Avoid pure black (#000) on pure white; slightly soften both to reduce eye strain.
  • Offer a high-contrast theme token set if your audience requires it.

9) Performance & correctness

  • Do not re-compute themes in heavy JS during runtime; rely on CSS variables.
  • Keep the head script tiny and synchronous (no imports).
  • Scope animations that depend on theme to respect prefers-reduced-motion.
  • Test across color profiles and HDR/OLED screens (deep blacks sometimes crush detail).

10) Security & privacy notes

  • Theme is non-sensitive, but still set cookies with SameSite=Lax and a sane Max-Age.
  • If you mirror to a server profile, debounce updates to avoid extra writes.

11) Scaling beyond two themes

Because you used tokens, adding themes is easy:

:root[data-theme="dim"] { /* tweak only a few tokens */ }
:root[data-theme="hc"] { /* high-contrast overrides */ }
:root[data-brand="acme"] { /* brand accents without redoing dark/light */ }

Layer themes (data-theme) and brands (data-brand) independently. This avoids a combinatorial explosion (dark-brandA, light-brandA, etc.).

12) Testing checklist

  • First paint shows correct theme (no flash).
  • Toggle persists across page reloads and routes.
  • System mode follows OS changes.
  • Cookies respected in SSR (open a new tab/window).
  • Contrast and focus states pass in both themes.

TL;DR Implementation Plan

  1. Define tokens with CSS variables; avoid raw colors in components.
  2. Respect system preference by default.
  3. Persist with cookie + localStorage (and DB for signed-in users).
  4. Inline a pre-CSS head script to set data-theme on first paint.
  5. SSR reads cookie and sets HTML attributes to prevent flashes.
  6. Test accessibility and scale by adding more token sets later.

Build it once, scale it forever. Your users get the right theme instantly, and your team gets a system that grows with the product not against it.