How would you build a theming system (light/dark mode) using CSS variables?
Advertisement
🧩 Scenario
Architecture Walkthrough
Semantic Tokens, Not Colour Names
The first decision determines whether theming works at all. Tokens must be named by role, not by appearance.
--color-white cannot be dark. --color-gray-100 cannot be a dark surface. If tokens are named for their light-mode values, every dark-mode override makes the name a lie, and any developer reading background: var(--color-white) in a dark theme has to hold a contradiction in their head.
Naming by role solves it: --surface, --surface-raised, --text-primary, --text-muted, --border, --accent. Each holds a different value per theme and the name stays accurate in both.
The usual structure has two layers. A primitive layer of raw values that never changes, such as --blue-500: #3b82f6, and a semantic layer that maps roles to primitives per theme, such as --accent: var(--blue-500) in light and --accent: var(--blue-400) in dark. Components only ever reference semantic tokens. This keeps the palette stable, makes the per-theme diff small and reviewable, and means adding a third theme is a new semantic block rather than a component audit.
Three States: System, Light, Dark
A complete system supports the OS preference and a manual override, because users legitimately want a site dark while their system is light. That means three states, not two: follow the system, force light, force dark.
The structure that expresses this cleanly puts the system preference in a media query and the manual override in an attribute selector that outranks it:
:root { /* light tokens */ }
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) { /* dark tokens */ }
}
[data-theme='dark'] { /* dark tokens */ }
The :not([data-theme='light']) is what makes "force light on a dark system" work: without it, the media query would still apply dark tokens and the explicit light choice would be ignored. The [data-theme='dark'] selector then handles "force dark on a light system." Storing only an explicit choice, and treating its absence as "follow the system," keeps the three states distinct.
@custom-media or a small amount of duplication is usually acceptable here; the alternative of extracting the dark token block into a reusable place with plain CSS requires either a preprocessor or duplication, and duplication of one token block is the lesser cost.
Preventing the Flash of Incorrect Theme
If the stored preference is applied by application code after the framework hydrates, the first paint uses the default theme and then swaps. In dark mode this is a full-screen white flash on every navigation, and it is one of the most reported dark-mode defects.
The fix is a small blocking script in the document head that reads storage and sets the attribute before the first paint:
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light') document.documentElement.dataset.theme = t;
})();
</script>
It must be inline and synchronous. A deferred or module script runs too late, and moving the logic into a component runs later still. In Next.js this belongs in the root layout as a <script> with dangerouslySetInnerHTML, which is exactly what next-themes does internally.
color-scheme Is Not Optional
Custom properties style your CSS. They do not touch browser-rendered UI: the default scrollbar, form control internals, the caret, spellcheck underlines, and <select> dropdowns. Without telling the browser, those stay light while your page goes dark, which looks broken in a specific and recognisable way.
The color-scheme property is what fixes it:
:root { color-scheme: light; }
[data-theme='dark'] { color-scheme: dark; }
It also makes the browser's own default colours sensible, and it can be declared with both values, color-scheme: light dark, to indicate support for either. Pair it with the <meta name="color-scheme"> tag for the earliest possible effect.
Transitions and Reduced Motion
A theme swap changes many properties at once. Transitioning them looks deliberate but is expensive, and transition: all on the root is genuinely bad: it animates layout properties too and produces a visible, janky repaint of the whole page.
If a transition is wanted, restrict it to background-color, color, and border-color, keep it short, and respect prefers-reduced-motion. A common refinement is to apply the transition only for the duration of the swap by adding a class, so it does not slow every subsequent hover and focus interaction.
Contrast in Both Themes
Finally, a theming system is an accessibility surface. A colour pair that meets WCAG AA in light mode frequently fails in dark mode, because perceived contrast is not symmetric: pure white text on a pure black background is uncomfortably high contrast, while mid greys that read well on white become too low on dark. Dark themes typically want a slightly-off-black surface such as #111827 rather than #000, and lighter, less saturated accents. Every semantic pair should be checked in both themes rather than assumed to carry over, and prefers-contrast: more is worth supporting as a third axis.
Key Code Explained
/* LAYER 1: primitives — raw values, theme-agnostic, never change */
:root {
--blue-400: #60a5fa;
--blue-500: #3b82f6;
--gray-50: #f8fafc;
--gray-200: #e2e8f0;
--gray-700: #334155;
--gray-900: #0f172a;
--white: #fff;
}
/* LAYER 2: semantic tokens — named by ROLE, one value per theme */
:root {
color-scheme: light;
--surface: var(--white);
--surface-raised: var(--gray-50);
--text-primary: var(--gray-900);
--text-muted: var(--gray-700);
--border: var(--gray-200);
--accent: var(--blue-500);
}
/* Follow the OS preference — unless the user explicitly chose light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']) {
color-scheme: dark;
--surface: #111827; /* not pure black: less harsh */
--surface-raised: #1f2937;
--text-primary: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--accent: var(--blue-400); /* lighter accent reads better on dark */
}
}
/* Manual override: force dark on a light system */
[data-theme='dark'] {
color-scheme: dark;
--surface: #111827;
--surface-raised: #1f2937;
--text-primary: #f1f5f9;
--text-muted: #94a3b8;
--border: #334155;
--accent: var(--blue-400);
}
/* Components reference SEMANTIC tokens only — never primitives,
and never a colour-named token */
.card {
background: var(--surface-raised);
color: var(--text-primary);
border: 1px solid var(--border);
}
/* Scoped theming falls out for free: an inverted section is one block */
.section--inverted {
--surface: #111827;
--text-primary: #f1f5f9;
}
/* Transition only the colour properties, and respect reduced motion */
:root {
transition: background-color 150ms ease, color 150ms ease;
}
@media (prefers-reduced-motion: reduce) {
:root { transition: none; }
}
/* NEVER transition: all on the root — it animates layout too */
<!-- No-flash: inline and BLOCKING, before first paint -->
<meta name="color-scheme" content="light dark" />
<script>
(function () {
var t = localStorage.getItem('theme');
if (t === 'dark' || t === 'light') document.documentElement.dataset.theme = t;
})();
</script>
// Three states: explicit dark, explicit light, or follow the system
function setTheme(choice) {
const root = document.documentElement;
if (choice === 'system') {
localStorage.removeItem('theme');
delete root.dataset.theme; // the media query takes over again
} else {
localStorage.setItem('theme', choice);
root.dataset.theme = choice;
}
}
The :root:not([data-theme='light']) selector is the single most important line in the CSS. Without the :not(), a user on a dark system who explicitly picks light mode still gets dark tokens, because the media query matches and nothing in the light path outranks it. That one selector is what makes three genuine states possible rather than two.
The inline script matters just as much for perceived quality. It is a few lines that must run synchronously in the head, and no amount of correct CSS or framework-level state management removes the flash if the attribute is set after hydration.
Tradeoffs
| Decision | Option A | Option B | Recommendation |
|---|---|---|---|
| Token naming | Colour names (--gray-100) | Semantic roles (--surface) | Semantic; primitives stay as a separate layer |
| Theme switching | Class on <body> | data-theme attribute on <html> | Attribute on <html>; available before body renders |
| Preference source | prefers-color-scheme only | System + manual override | Both, giving three states |
| Applying stored theme | In app code after hydration | Inline blocking script in head | Inline script; the only no-flash option |
| Native UI | Ignore | color-scheme property | Always set it |
| Swap transition | transition: all | Colour properties only, short | Colour properties, gated on reduced motion |
What Interviewers Actually Check
- Whether you name tokens semantically and can explain why colour names break
- Whether you support both the system preference and a manual override, and handle all three states
- Whether you raise the flash-of-incorrect-theme problem and know the blocking-script fix
- Whether you know
color-schemeexists and what it covers that custom properties cannot - Whether you treat contrast in both themes as a real requirement rather than an assumption
Follow-Up Questions
- How would you add a third theme, such as high contrast, without touching component styles?
- How does
light-dark()work, and where does it simplify or complicate the structure above? - How would you store the preference for a logged-in user across devices, and how does that interact with server rendering?
- What accessibility considerations apply beyond contrast, for example
prefers-contrastand forced-colors mode? - How would you test that every semantic token pair meets contrast requirements in both themes automatically?
Common Candidate Mistakes
- Naming tokens after their light-mode appearance, so
--color-whitehas to hold a dark value and every component rule becomes misleading - Supporting only
prefers-color-scheme, which leaves users unable to choose a theme per site independently of their operating system - Applying the stored preference in application code after hydration, producing a white flash on every load for dark-mode users
- Omitting
color-scheme, so scrollbars, form controls, the caret, and<select>dropdowns stay light while the page is dark - Putting
transition: allon the root to smooth the swap, which animates layout properties and repaints the entire page
Interview Readiness Checklist
Before you leave this question, make sure you can answer:
- Can you design a semantic token layer and justify the naming against colour-based names?
- Can you write the CSS structure that handles all three states: system, forced light, and forced dark?
- Can you write the no-flash inline script and say why it must be blocking?
- Can you explain what
color-schemedoes that custom properties cannot? - Can you explain why contrast must be verified separately in each theme?
Summary
A theming system built on custom properties starts with naming. Tokens must describe roles, not colours, because --color-white cannot hold a dark value without the name becoming false. The standard structure is two layers: theme-agnostic primitives such as --blue-500, and semantic tokens such as --surface, --text-primary, and --accent that map roles to primitives differently per theme. Components reference only semantic tokens, so adding a theme is a new token block rather than a component audit.
A complete implementation supports three states rather than two: follow the OS preference, force light, and force dark. That is expressed with light tokens on :root, dark tokens inside @media (prefers-color-scheme: dark) scoped to :root:not([data-theme='light']), and dark tokens again under [data-theme='dark']. The :not() is essential; without it an explicit light choice on a dark system is ignored. Storing only explicit choices and treating their absence as "system" keeps the states distinct.
Two things are easy to omit and very visible when missing. The stored preference must be applied by an inline, blocking script in the head, or dark-mode users see a white flash on every load no matter how correct the CSS is. And color-scheme must be set per theme, since custom properties cannot restyle scrollbars, form control internals, or the caret. Finally, keep swap transitions limited to colour properties and gated on prefers-reduced-motion, and verify contrast independently in each theme, because a pair that passes WCAG AA in light mode frequently fails in dark.
Why use semantic token names instead of colour names?
Because --color-white cannot be dark. Naming tokens by role, such as --surface or --text-primary, lets the same token hold a different value per theme without the name becoming a lie.
What causes the flash of the wrong theme on load?
Applying the stored theme after React or another framework hydrates. A tiny blocking script in the head that sets the attribute before first paint eliminates it.
Advertisement
Stay Updated
Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.
Advertisement