Designing for the System: Implementing Advanced Theme Engines
Article content
The Flash That Breaks Trust
You've seen it: a user with dark mode enabled loads a page, gets a blinding white flash, then the theme kicks in. That flash is a broken promise: the site asked for their preference and then ignored it for 200ms. On every refresh.
Building a theme engine that's performant, flash-free, and accessible isn't just a polish task. For any app where users return regularly, it's a baseline expectation.
The Modern Stack: next-themes + CSS Variables
Combining the next-themes library with a well-structured CSS Variable system covers theming for almost any Next.js app without extra client state.
1. Defining the Variables
Instead of hardcoding hex values, we define a "semantic" layer in our SCSS:
:root {
--bg-body: #ffffff;
--text-main: #000000;
}
.dark {
--bg-body: #0a0a0a;
--text-main: #ffffff;
}2. Avoiding the "Flash of Unstyled Content"
One of the biggest challenges with SSR (Server Side Rendering) is the "white flash" when a dark-mode user reloads the page. next-themes solves this by injecting a script into the <head> that applies the correct class before the page even renders.
Best Practices for System Design
- Semantic Naming: Use names like
--accent-coloror--border-subtlerather than--blue-500. - Optical Adjustments: Colors often need slight shifts in saturation or brightness when moved to dark mode to maintain the same perceived contrast.
- Motion & Glassmorphism: Ensure transitions are smooth and that glass effects (blur) remain legible across all backgrounds.
Three Tiers of Tokens, Not One
The flat variable list above works for a toggle. It stops working the moment a second brand, a high-contrast mode, or a marketing sub-site arrives. The pattern that scales is a three-tier taxonomy:
- Primitive tokens hold raw values and never appear in component code:
--grey-900: #0a0a0a,--yellow-400: #facc15. - Semantic tokens map primitives to roles:
--bg-body: var(--grey-900),--accent-color: var(--yellow-400). Themes swap at this layer. - Component tokens (optional, for design systems) scope semantics to a component:
--button-bg: var(--accent-color).
Components only ever consume semantic or component tokens. When the third theme arrives, you write one new mapping block; no component files change. This site generates its token layer with Style Dictionary from a single JSON source, which means the same tokens can compile to SCSS variables, CSS custom properties, and a TypeScript export for use in JS-driven styling. One source of truth, three output formats, zero drift.
The Details Everyone Misses
color-scheme, the one-line fix for native UI. Your CSS variables don't reach scrollbars, form controls, or the default focus ring. Declare color-scheme: light dark on :root (and the specific value per theme class) and the browser renders native UI to match. Without it, dark mode ships with a glaring white scrollbar on Windows.
Kill transitions during the switch. If your elements have transition: background-color 0.3s, toggling the theme triggers hundreds of staggered crossfades: it reads as lag, not polish. The trick is a temporary class that disables transitions for one frame:
document.documentElement.classList.add('theme-switching');
setTheme(next);
requestAnimationFrame(() =>
document.documentElement.classList.remove('theme-switching')
);.theme-switching * { transition: none !important; }The theme snaps instantly, which is what users actually expect from a toggle.
Three states, not two. A proper toggle cycles light → dark → system. Users who choose "system" get automatic switching at sunset via prefers-color-scheme, and your stored preference should record that they chose system, not the value it resolved to. next-themes models this correctly out of the box, so resist simplifying it to a boolean.
suppressHydrationWarning is part of the deal. The head script sets the theme class before React hydrates, so the server-rendered <html> attribute won't match. That one warning suppression on the <html> element is expected and documented, not a hack.
Test the Contrast, Not the Vibe
Dark themes fail accessibility audits more often than light ones, and it's rarely the body text: it's the muted secondary text, the placeholder copy, and the borders that quietly fall below 3:1. Every semantic pair (--text-muted on --bg-body, --accent-color on --accent-foreground) needs checking in both themes, because passing in light says nothing about dark.
Two habits make this sustainable: check contrast at the token level when you define the pair (there's a WCAG AA/AAA checker at mirzaa.dev/tools/contrast-checker built for exactly this), and re-check only the changed tokens in review. Auditing pairs is cheap; auditing rendered pages is endless.
Conclusion
The next-themes + CSS Variables pattern scales cleanly from a simple dark/light toggle to full multi-brand theming. The key discipline is keeping token names semantic. Once you start naming tokens after their role (--bg-body, --border-subtle) rather than their value (--grey-100), adding a third theme or a high-contrast accessibility mode becomes a matter of adding one more variable block rather than a find-and-replace across the codebase.
If you're starting fresh: set up the variable structure first, wire next-themes second. Don't add the library before you have a clear token taxonomy: it won't solve the hard part.
Sources & References
- next-themes on GitHub
- MDN Web Docs: CSS Custom Properties
- W3C WCAG: Contrast Minimum
- Material Design: Dark Theme Guidelines
Suggested Reading
Architectural Note: Research, drafting, and code for this post were augmented by Gemini (Google DeepMind), directed and verified by Maas Mirzaa. How this workflow works →