Implementing dark mode and light mode
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.
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.
/* 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 */
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.
// 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);
}
});
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.
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>
);
}
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.
<!DOCTYPE html>
<html lang="en">
<head>
<!-- This script runs BEFORE the page renders — prevents 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
-->
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
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