How would you build a theming system (light/dark mode) using CSS variables?

Advanced20 min interview
Skills tested:
Designing semantic tokens rather than literal colour namesCombining prefers-color-scheme with a manual overridePreventing the flash of incorrect themeUsing the color-scheme property for native form controls and scrollbarsHandling transitions and prefers-reduced-motion during a theme switch

Advertisement

🧩 Scenario

A theming system is the most common real application of custom properties, and it is a design problem before it is a CSS problem. Naming the tokens wrong makes dark mode a rewrite rather than a value swap; supporting only the OS preference means users cannot choose per site; applying the stored theme after hydration produces a white flash on every load in dark mode. Each of those is a decision made in the first hour and paid for over the project lifetime.

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

DecisionOption AOption BRecommendation
Token namingColour names (--gray-100)Semantic roles (--surface)Semantic; primitives stay as a separate layer
Theme switchingClass on <body>data-theme attribute on <html>Attribute on <html>; available before body renders
Preference sourceprefers-color-scheme onlySystem + manual overrideBoth, giving three states
Applying stored themeIn app code after hydrationInline blocking script in headInline script; the only no-flash option
Native UIIgnorecolor-scheme propertyAlways set it
Swap transitiontransition: allColour properties only, shortColour 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-scheme exists 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

  1. How would you add a third theme, such as high contrast, without touching component styles?
  2. How does light-dark() work, and where does it simplify or complicate the structure above?
  3. How would you store the preference for a logged-in user across devices, and how does that interact with server rendering?
  4. What accessibility considerations apply beyond contrast, for example prefers-contrast and forced-colors mode?
  5. 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-white has 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: all on 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-scheme does 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.

Frequently Asked Questions

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