CSSJavaScriptMedium

Implementing dark mode and light mode

01

The Short Answer

Dark mode implementation relies on CSS custom properties (variables) that change based on a theme class or attribute on the root element. You define two sets of color values — one for light, one for dark — and swap between them by toggling a class (like .dark) or a data-theme attribute on <html>. The user's preference is detected via the prefers-color-scheme media query (OS-level setting) and can be overridden with a manual toggle that persists the choice in localStorage. All component colors reference the CSS variables, so switching themes requires zero component changes.

02

CSS Custom Properties Approach

The foundation is defining color tokens as CSS variables under different selectors. When the theme changes, the variables resolve to different values, and every element using those variables updates automatically. This is the approach used by Tailwind CSS, shadcn/ui, and most modern design systems.

theme-variables.csscss
/* Light theme (default) */
:root {
  --background: 0 0% 100%;       /* white */
  --foreground: 0 0% 9%;         /* near-black */
  --card: 0 0% 100%;
  --card-foreground: 0 0% 9%;
  --primary: 142 71% 35%;        /* green */
  --primary-foreground: 0 0% 100%;
  --muted: 0 0% 96%;
  --muted-foreground: 0 0% 45%;
  --border: 0 0% 90%;
}

/* Dark theme */
.dark {
  --background: 0 0% 7%;         /* near-black */
  --foreground: 0 0% 95%;        /* near-white */
  --card: 0 0% 12%;
  --card-foreground: 0 0% 95%;
  --primary: 142 71% 45%;        /* brighter green */
  --primary-foreground: 0 0% 9%;
  --muted: 0 0% 15%;
  --muted-foreground: 0 0% 64%;
  --border: 0 0% 20%;
}

/* Components use variables — never raw colors */
body {
  background-color: hsl(var(--background));
  color: hsl(var(--foreground));
}

.card {
  background-color: hsl(var(--card));
  color: hsl(var(--card-foreground));
  border: 1px solid hsl(var(--border));
}

/* Switching themes = toggling .dark on <html>
   ALL colors update automatically — zero component changes */
03

Detecting System Preference

The prefers-color-scheme media query detects the user's OS-level dark mode setting. You can use it in CSS directly (for a CSS-only solution) or in JavaScript (to set the initial theme and listen for changes). Most implementations combine this with a manual override — respect the system preference by default, but let users choose explicitly.

detect-preference.tstypescript
// CSS-only detection (no JS needed for basic support)
// @media (prefers-color-scheme: dark) {
//   :root { --background: 0 0% 7%; ... }
// }

// JavaScript detection + manual override
type Theme = 'light' | 'dark' | 'system';

function getSystemTheme(): 'light' | 'dark' {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}

function getStoredTheme(): Theme {
  return (localStorage.getItem('theme') as Theme) ?? 'system';
}

function applyTheme(theme: Theme) {
  const resolved = theme === 'system' ? getSystemTheme() : theme;

  // Toggle the .dark class on <html>
  document.documentElement.classList.toggle('dark', resolved === 'dark');

  // Persist the user's explicit choice
  localStorage.setItem('theme', theme);
}

// Initialize on page load
applyTheme(getStoredTheme());

// Listen for OS-level theme changes (user toggles system dark mode)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {
  // Only react if user hasn't set an explicit preference
  if (getStoredTheme() === 'system') {
    document.documentElement.classList.toggle('dark', event.matches);
  }
});
04

Theme Toggle Component

A theme toggle gives users explicit control. The typical UX offers three options: light, dark, and system (follow OS). The toggle updates the class on the document element and persists the choice. In React/Next.js apps, libraries like next-themes handle this with a provider pattern that avoids flash of wrong theme on page load.

theme-toggle.tsxtsx
import { useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

function useTheme() {
  const [theme, setTheme] = useState<Theme>(() => {
    if (typeof window === 'undefined') return 'system';
    return (localStorage.getItem('theme') as Theme) ?? 'system';
  });

  useEffect(() => {
    const root = document.documentElement;
    const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    const isDark = theme === 'dark' || (theme === 'system' && systemDark);

    root.classList.toggle('dark', isDark);
    localStorage.setItem('theme', theme);
  }, [theme]);

  return { theme, setTheme };
}

function ThemeToggle() {
  const { theme, setTheme } = useTheme();

  return (
    <select
      value={theme}
      onChange={(event) => setTheme(event.target.value as Theme)}
      aria-label="Select color theme"
    >
      <option value="light">Light</option>
      <option value="dark">Dark</option>
      <option value="system">System</option>
    </select>
  );
}
05

Preventing Flash of Wrong Theme

The biggest UX issue with dark mode is the flash of light theme on page load — the HTML renders with default styles before JavaScript runs and applies the correct theme class. The fix is to run a tiny blocking script in the <head> (before the body renders) that reads localStorage and applies the class immediately. This script must be synchronous and inline — not deferred or async.

prevent-flash.htmlhtml
<!DOCTYPE html>
<html lang="en">
<head>
  <!-- This script runs BEFORE the page rendersprevents flash -->
  <script>
    (function() {
      const stored = localStorage.getItem('theme');
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const isDark = stored === 'dark' || (stored !== 'light' && systemDark);
      if (isDark) {
        document.documentElement.classList.add('dark');
      }
    })();
  </script>
  <!-- Rest of head -->
</head>
<body>
  <!-- Page renders with correct theme from the start -->
</body>
</html>

<!-- In Next.js, use next-themes which handles this automatically:
  - Adds the blocking script via a <script> in the layout
  - Provides useTheme() hook
  - Handles SSR correctly (avoids hydration mismatch)
  - Supports system preference + manual override
-->
06

Design Considerations

Dark mode best practices

  • Use semantic color tokens (--foreground, --muted) not literal names (--black, --white)
  • Reduce contrast slightly in dark mode — pure white on pure black causes eye strain
  • Adjust shadows — use lighter/colored shadows in dark mode (dark shadows are invisible)
  • Test images and illustrations — some look wrong on dark backgrounds
  • Respect prefers-color-scheme as the default, allow manual override
  • Persist user choice in localStorage, not cookies (avoids server round-trip)

Common mistakes to avoid

  • Using raw hex colors in components instead of CSS variables
  • Forgetting to handle the flash of wrong theme on page load
  • Inverting all colors mechanically — dark mode needs intentional design
  • Using pure black (#000) backgrounds — too harsh, use near-black instead
  • Not testing with both themes during development
07

Why Interviewers Ask This

Dark mode implementation tests multiple skills at once. Interviewers want to see that you understand CSS custom properties and how they enable theming, know the prefers-color-scheme media query for system detection, can handle the flash-of-wrong-theme problem (blocking script), understand localStorage for persisting preferences, and think about the architecture (semantic tokens, not hardcoded colors). It's a practical question that reveals whether you've built production-quality UIs with proper theming support.

Quick Revision Cheat Sheet

Mechanism: CSS variables under :root (light) and .dark (dark) — toggle class on <html>

System detection: prefers-color-scheme media query — CSS or matchMedia() in JS

Persistence: localStorage.setItem('theme', choice) — read on page load

Flash prevention: Inline blocking <script> in <head> that applies .dark before render

Three modes: Light, Dark, System (follow OS) — system is the default

Architecture: Semantic tokens (--foreground) not literal (--white) — components never use raw colors