CSS3 & Responsive Design

Dark Mode with prefers-color-scheme

25 min Lesson 54 of 60

What Is prefers-color-scheme?

The prefers-color-scheme CSS media feature detects whether the user has requested a light or dark color theme through their operating system settings. On macOS, this is the Appearance setting in System Preferences. On Windows, it is the "Choose your color" option in Personalization settings. On iOS and Android, it is the Dark Mode toggle. When a user switches their system to dark mode, prefers-color-scheme: dark becomes true, and your CSS can respond automatically -- no JavaScript needed for the initial detection.

Dark mode is no longer a luxury feature; it is an expectation. Users prefer dark interfaces for reduced eye strain in low-light environments, lower battery consumption on OLED screens, and personal aesthetic preference. By using prefers-color-scheme, you respect the user's system-wide preference and deliver the correct theme immediately on page load, avoiding the flash of the wrong color scheme that plagues many dark mode implementations.

Detecting System Preference with CSS

The prefers-color-scheme media feature works like any other CSS media query. It accepts two values: light and dark. You wrap your dark-mode-specific styles inside the media query, and the browser applies them automatically when the system is in dark mode.

Basic prefers-color-scheme Usage

/* Default light mode styles */
body {
    background-color: #ffffff;
    color: #1a1a2e;
}

a {
    color: #2563eb;
}

/* Dark mode overrides */
@media (prefers-color-scheme: dark) {
    body {
        background-color: #1a1a2e;
        color: #e2e8f0;
    }

    a {
        color: #60a5fa;
    }
}

/* You can also target light mode explicitly */
@media (prefers-color-scheme: light) {
    body {
        background-color: #ffffff;
        color: #1a1a2e;
    }
}

/* Check for no preference (rare, but possible) */
/* If neither light nor dark matches, the default styles apply */
Note: The order matters. If you write your default styles assuming light mode and then override with prefers-color-scheme: dark, you follow a "light-first" approach. Alternatively, you can write default dark styles and override with prefers-color-scheme: light for a "dark-first" approach. The light-first approach is more common because it matches traditional web design conventions and provides a better fallback for browsers that do not support the media feature.

The color-scheme Property and Meta Tag

The CSS color-scheme property and the corresponding HTML meta tag tell the browser which color schemes your page supports. This is important because the browser uses this information to style built-in UI elements like form controls, scrollbars, and the default background color before your CSS even loads.

The color-scheme CSS Property and HTML Meta Tag

/* Tell the browser this page supports both light and dark */
:root {
    color-scheme: light dark;
}

/* Browser will automatically adjust:
   - Default background (white in light, dark gray in dark)
   - Default text color (black in light, white in dark)
   - Form controls (inputs, selects, checkboxes)
   - Scrollbar colors
   - System colors like Canvas, CanvasText, etc. */

/* You can also specify only one scheme */
:root {
    color-scheme: light;
    /* Forces light appearance for all browser UI */
}

:root {
    color-scheme: dark;
    /* Forces dark appearance for all browser UI */
}

HTML Meta Tag for color-scheme

<!-- Add this to your <head> for immediate browser theming -->
<meta name="color-scheme" content="light dark">

<!-- This ensures the browser renders a dark background
     BEFORE your CSS loads, preventing a white flash
     when the user has dark mode enabled.

     Without this meta tag, the browser defaults to a
     white background during page load, causing a brief
     flash of white even if your CSS defines a dark
     background. -->
Tip: Always include both the <meta name="color-scheme"> tag in your HTML head and the color-scheme CSS property on :root. The meta tag prevents the flash of incorrect background color during page load, while the CSS property gives you finer control and can be changed dynamically. Together, they provide the smoothest dark mode experience.

Building a Color Token System with CSS Custom Properties

The most maintainable approach to dark mode is building a complete color token system using CSS custom properties (variables). Instead of scattering color values throughout your stylesheets, you define all your colors as variables on :root and then override them inside a prefers-color-scheme: dark media query. Your component styles reference only the variables, never raw color values.

Complete Color Token System

/* ===== Light Mode Tokens (Default) ===== */
:root {
    color-scheme: light dark;

    /* Background colors */
    --color-bg-primary: #ffffff;
    --color-bg-secondary: #f8fafc;
    --color-bg-tertiary: #f1f5f9;
    --color-bg-elevated: #ffffff;
    --color-bg-overlay: rgba(0, 0, 0, 0.5);

    /* Surface colors (cards, panels) */
    --color-surface: #ffffff;
    --color-surface-hover: #f8fafc;
    --color-surface-active: #f1f5f9;

    /* Text colors */
    --color-text-primary: #0f172a;
    --color-text-secondary: #475569;
    --color-text-tertiary: #94a3b8;
    --color-text-inverse: #ffffff;
    --color-text-link: #2563eb;
    --color-text-link-hover: #1d4ed8;

    /* Border colors */
    --color-border-primary: #e2e8f0;
    --color-border-secondary: #cbd5e1;
    --color-border-focus: #2563eb;

    /* Brand / accent colors */
    --color-accent: #2563eb;
    --color-accent-hover: #1d4ed8;
    --color-accent-light: #dbeafe;
    --color-accent-text: #ffffff;

    /* Semantic colors */
    --color-success: #16a34a;
    --color-success-bg: #f0fdf4;
    --color-warning: #d97706;
    --color-warning-bg: #fffbeb;
    --color-error: #dc2626;
    --color-error-bg: #fef2f2;
    --color-info: #2563eb;
    --color-info-bg: #eff6ff;

    /* Shadows */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
    --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1),
                 0 2px 4px -2px rgba(0, 0, 0, 0.1);
    --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1),
                 0 4px 6px -4px rgba(0, 0, 0, 0.1);
}

/* ===== Dark Mode Token Overrides ===== */
@media (prefers-color-scheme: dark) {
    :root {
        /* Background colors */
        --color-bg-primary: #0f172a;
        --color-bg-secondary: #1e293b;
        --color-bg-tertiary: #334155;
        --color-bg-elevated: #1e293b;
        --color-bg-overlay: rgba(0, 0, 0, 0.7);

        /* Surface colors */
        --color-surface: #1e293b;
        --color-surface-hover: #334155;
        --color-surface-active: #475569;

        /* Text colors */
        --color-text-primary: #f1f5f9;
        --color-text-secondary: #cbd5e1;
        --color-text-tertiary: #64748b;
        --color-text-inverse: #0f172a;
        --color-text-link: #60a5fa;
        --color-text-link-hover: #93bbfd;

        /* Border colors */
        --color-border-primary: #334155;
        --color-border-secondary: #475569;
        --color-border-focus: #60a5fa;

        /* Brand / accent colors */
        --color-accent: #3b82f6;
        --color-accent-hover: #60a5fa;
        --color-accent-light: #1e3a5f;
        --color-accent-text: #ffffff;

        /* Semantic colors */
        --color-success: #4ade80;
        --color-success-bg: #052e16;
        --color-warning: #fbbf24;
        --color-warning-bg: #422006;
        --color-error: #f87171;
        --color-error-bg: #450a0a;
        --color-info: #60a5fa;
        --color-info-bg: #172554;

        /* Shadows (stronger in dark mode for visibility) */
        --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
        --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4),
                     0 2px 4px -2px rgba(0, 0, 0, 0.3);
        --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.5),
                     0 4px 6px -4px rgba(0, 0, 0, 0.4);
    }
}

Using Color Tokens in Components

Once your token system is in place, every component in your application references the variables instead of hardcoded color values. This means that switching between light and dark mode happens entirely through the variable overrides -- your component CSS never needs to change.

Components Using Color Tokens

/* All components reference tokens -- never raw colors */

/* Page layout */
body {
    background-color: var(--color-bg-primary);
    color: var(--color-text-primary);
    transition: background-color 0.3s ease, color 0.3s ease;
}

/* Card component */
.card {
    background: var(--color-surface);
    border: 1px solid var(--color-border-primary);
    border-radius: 0.75rem;
    padding: 1.5rem;
    box-shadow: var(--shadow-md);
}

.card:hover {
    background: var(--color-surface-hover);
    border-color: var(--color-border-secondary);
}

.card__title {
    color: var(--color-text-primary);
    font-size: 1.25rem;
    margin-bottom: 0.5rem;
}

.card__description {
    color: var(--color-text-secondary);
    line-height: 1.6;
}

/* Button component */
.btn-primary {
    background: var(--color-accent);
    color: var(--color-accent-text);
    border: none;
    padding: 0.75rem 1.5rem;
    border-radius: 0.5rem;
    cursor: pointer;
    transition: background-color 0.2s ease;
}

.btn-primary:hover {
    background: var(--color-accent-hover);
}

.btn-secondary {
    background: transparent;
    color: var(--color-text-primary);
    border: 1px solid var(--color-border-primary);
    padding: 0.75rem 1.5rem;
    border-radius: 0.5rem;
}

/* Navigation */
.nav {
    background: var(--color-bg-elevated);
    border-bottom: 1px solid var(--color-border-primary);
    box-shadow: var(--shadow-sm);
}

.nav-link {
    color: var(--color-text-secondary);
    text-decoration: none;
    padding: 0.75rem 1rem;
}

.nav-link:hover {
    color: var(--color-text-primary);
}

.nav-link.active {
    color: var(--color-accent);
}

/* Alert / notification component */
.alert {
    padding: 1rem 1.25rem;
    border-radius: 0.5rem;
    font-size: 0.875rem;
}

.alert--success {
    background: var(--color-success-bg);
    color: var(--color-success);
    border: 1px solid var(--color-success);
}

.alert--error {
    background: var(--color-error-bg);
    color: var(--color-error);
    border: 1px solid var(--color-error);
}

.alert--warning {
    background: var(--color-warning-bg);
    color: var(--color-warning);
    border: 1px solid var(--color-warning);
}

/* Input fields */
.input {
    background: var(--color-bg-primary);
    color: var(--color-text-primary);
    border: 1px solid var(--color-border-primary);
    padding: 0.75rem 1rem;
    border-radius: 0.375rem;
    font-size: 1rem;
    width: 100%;
}

.input:focus {
    border-color: var(--color-border-focus);
    outline: none;
    box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.2);
}

.input::placeholder {
    color: var(--color-text-tertiary);
}

Handling Images in Dark Mode

Images require special attention in dark mode. Bright images on a dark background can feel glaring, and images with transparent backgrounds (like logos) may become invisible. There are several strategies for handling images across color schemes.

Image Handling Strategies for Dark Mode

/* Strategy 1: Reduce image brightness and increase contrast */
@media (prefers-color-scheme: dark) {
    img:not([src*=".svg"]) {
        filter: brightness(0.9) contrast(1.05);
    }
}

/* Strategy 2: Use the <picture> element for different images */
/* HTML:
<picture>
    <source srcset="logo-dark.png" media="(prefers-color-scheme: dark)">
    <img src="logo-light.png" alt="Company Logo">
</picture>
*/

/* Strategy 3: Swap images using CSS background */
.hero-image {
    background-image: url('hero-light.jpg');
    background-size: cover;
}

@media (prefers-color-scheme: dark) {
    .hero-image {
        background-image: url('hero-dark.jpg');
    }
}

/* Strategy 4: Invert SVG icons that are pure black/white */
@media (prefers-color-scheme: dark) {
    .icon-dark-invert {
        filter: invert(1);
    }
}

/* Strategy 5: Use CSS to handle transparent PNG logos */
.logo {
    /* Light mode: dark logo on light background */
}

@media (prefers-color-scheme: dark) {
    .logo {
        /* Option A: invert the logo */
        filter: invert(1) hue-rotate(180deg);

        /* Option B: add a subtle background */
        background: rgba(255, 255, 255, 0.1);
        padding: 0.5rem;
        border-radius: 0.25rem;
    }
}

/* Strategy 6: Use currentColor in SVGs for automatic theming */
/* SVG with fill="currentColor" inherits the text color */
.icon-svg {
    color: var(--color-text-primary);
    /* SVG fills automatically match the theme */
}

Handling Shadows and Borders in Dark Mode

Shadows and borders behave differently in dark mode. Shadows that look subtle against a light background become nearly invisible against a dark background. Borders that provide gentle separation in light mode can look harsh in dark mode. Your design tokens should account for these differences.

Shadow and Border Adjustments for Dark Mode

/* Light mode: subtle shadows for depth */
:root {
    --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.08),
                   0 1px 2px rgba(0, 0, 0, 0.06);
    --shadow-dropdown: 0 4px 12px rgba(0, 0, 0, 0.1);
    --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.15);

    --border-subtle: 1px solid rgba(0, 0, 0, 0.08);
    --border-default: 1px solid rgba(0, 0, 0, 0.12);
}

/* Dark mode: stronger shadows, softer borders */
@media (prefers-color-scheme: dark) {
    :root {
        /* Shadows need higher opacity to be visible on dark backgrounds */
        --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3),
                       0 1px 2px rgba(0, 0, 0, 0.2);
        --shadow-dropdown: 0 4px 12px rgba(0, 0, 0, 0.4);
        --shadow-modal: 0 20px 60px rgba(0, 0, 0, 0.6);

        /* Borders use lighter colors instead of darker */
        --border-subtle: 1px solid rgba(255, 255, 255, 0.06);
        --border-default: 1px solid rgba(255, 255, 255, 0.1);
    }
}

/* Alternative: use "glow" shadows in dark mode for elevated surfaces */
@media (prefers-color-scheme: dark) {
    .elevated-card {
        box-shadow:
            0 0 0 1px rgba(255, 255, 255, 0.05),
            0 4px 12px rgba(0, 0, 0, 0.4);
        /* The inner ring provides a subtle light edge */
    }
}

Contrast and Accessibility Considerations

Dark mode is not just about inverting colors. Poor dark mode implementations often fail accessibility standards by producing insufficient contrast between text and background. The Web Content Accessibility Guidelines (WCAG) require a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text. These requirements apply equally to both light and dark modes.

Ensuring Accessible Contrast in Dark Mode

/* WRONG: Common dark mode contrast mistakes */

/* Mistake 1: Text too dim on dark background */
.bad-dark-text {
    background: #1a1a2e;
    color: #555;
    /* Contrast ratio: ~2.1:1 -- FAILS WCAG AA */
}

/* Mistake 2: Pure white text on pure black -- too harsh */
.harsh-contrast {
    background: #000000;
    color: #ffffff;
    /* Contrast ratio: 21:1 -- technically passes but causes
       eye strain and halation (glowing text effect) */
}

/* RIGHT: Balanced dark mode contrast */
.good-dark-mode {
    background: #1e293b;  /* Dark slate, not pure black */
    color: #e2e8f0;       /* Off-white, not pure white */
    /* Contrast ratio: ~11.5:1 -- comfortable and accessible */
}

/* RIGHT: Semantic color contrast in dark mode */
:root {
    /* Light mode: dark text on light bg */
    --text-primary: #0f172a;    /* Very dark gray */
    --bg-primary: #ffffff;      /* White */
    /* Contrast: 18.4:1 */
}

@media (prefers-color-scheme: dark) {
    :root {
        /* Dark mode: light text on dark bg */
        --text-primary: #e2e8f0;    /* Light gray (not pure white) */
        --bg-primary: #0f172a;      /* Very dark blue-gray (not pure black) */
        /* Contrast: 12.6:1 -- excellent */
    }
}

/* Secondary text needs extra attention in dark mode */
:root {
    --text-secondary: #64748b;
    /* On white bg: contrast 5.4:1 -- passes AA */
}

@media (prefers-color-scheme: dark) {
    :root {
        --text-secondary: #94a3b8;
        /* On #0f172a bg: contrast 6.3:1 -- passes AA */
        /* Do NOT use #64748b in dark mode: only 3.1:1 on dark bg */
    }
}
Warning: Never use pure black (#000000) as your dark mode background. Pure black with white text causes "halation" -- a visual effect where bright text appears to bleed or glow against a true black background, making it harder to read. Instead, use a very dark gray or blue-gray like #0f172a, #1a1a2e, or #121212. Similarly, avoid pure white (#ffffff) text in dark mode; use off-white values like #e2e8f0 or #f1f5f9 for a more comfortable reading experience.

Manual Theme Toggle with JavaScript

While prefers-color-scheme automatically matches the system preference, many users want to override this on a per-site basis. A manual theme toggle lets users choose their preferred theme regardless of the system setting. The implementation uses a data attribute on the HTML element and CSS custom property overrides.

Theme Toggle Implementation Strategy

/* Step 1: Define themes using a data attribute instead of media queries */

/* Default: light theme */
:root {
    color-scheme: light dark;
    --color-bg: #ffffff;
    --color-text: #0f172a;
    --color-surface: #f8fafc;
    --color-border: #e2e8f0;
    --color-accent: #2563eb;
}

/* System dark mode (when no manual override) */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme]) {
        --color-bg: #0f172a;
        --color-text: #f1f5f9;
        --color-surface: #1e293b;
        --color-border: #334155;
        --color-accent: #3b82f6;
    }
}

/* Manual dark theme override */
[data-theme="dark"] {
    --color-bg: #0f172a;
    --color-text: #f1f5f9;
    --color-surface: #1e293b;
    --color-border: #334155;
    --color-accent: #3b82f6;
    color-scheme: dark;
}

/* Manual light theme override */
[data-theme="light"] {
    --color-bg: #ffffff;
    --color-text: #0f172a;
    --color-surface: #f8fafc;
    --color-border: #e2e8f0;
    --color-accent: #2563eb;
    color-scheme: light;
}

JavaScript Theme Toggle with localStorage

/* Step 2: JavaScript for the theme toggle button */

/* HTML:
<button id="theme-toggle" aria-label="Toggle dark mode">
    <span class="theme-icon-light">☀</span>
    <span class="theme-icon-dark">☾</span>
</button>
*/

// Get the stored theme or default to system preference
function getStoredTheme() {
    return localStorage.getItem('theme');
}

// Apply theme to the document
function applyTheme(theme) {
    if (theme === 'light' || theme === 'dark') {
        document.documentElement.setAttribute('data-theme', theme);
    } else {
        // 'system' -- remove attribute, let media query handle it
        document.documentElement.removeAttribute('data-theme');
    }
}

// Initialize theme on page load
function initTheme() {
    const stored = getStoredTheme();
    if (stored) {
        applyTheme(stored);
    }
    // If no stored preference, CSS media query handles it
}

// Toggle between light, dark, and system
function toggleTheme() {
    const current = getStoredTheme();
    const systemDark = window.matchMedia(
        '(prefers-color-scheme: dark)'
    ).matches;

    let next;
    if (!current || current === 'system') {
        // Currently following system; switch to opposite
        next = systemDark ? 'light' : 'dark';
    } else if (current === 'dark') {
        next = 'light';
    } else {
        next = 'dark';
    }

    localStorage.setItem('theme', next);
    applyTheme(next);
}

// Run on page load
initTheme();

// Attach to toggle button
document.getElementById('theme-toggle')
    .addEventListener('click', toggleTheme);

// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
    .addEventListener('change', (e) => {
        // Only react if user has not set a manual preference
        if (!getStoredTheme() || getStoredTheme() === 'system') {
            applyTheme('system');
        }
    });

Persisting Theme Preference in localStorage

The JavaScript above already uses localStorage to persist the theme choice. But there is a critical issue: there is a flash of the wrong theme before JavaScript runs. To solve this, you need to apply the stored theme as early as possible, ideally with an inline script in the <head> of your HTML document.

Preventing Theme Flash on Page Load

/* Place this inline script in your <head>, BEFORE any CSS */

/* HTML:
<head>
    <meta name="color-scheme" content="light dark">
    <script>
        // Immediately apply stored theme to prevent flash
        (function() {
            var theme = localStorage.getItem('theme');
            if (theme === 'dark' || theme === 'light') {
                document.documentElement.setAttribute('data-theme', theme);
            }
        })();
    </script>
    <link rel="stylesheet" href="styles.css">
</head>
*/

/* Why this works:
   1. The inline script runs synchronously before CSS loads
   2. It sets the data-theme attribute immediately
   3. When CSS loads, it sees the attribute and applies correct theme
   4. The user never sees a flash of the wrong color scheme

   The meta tag provides the correct default background color
   even before the script runs, giving the browser a hint about
   which color scheme to start with. */
Tip: For the flash prevention script, keep it as minimal as possible. It runs before CSS loads, so any delay directly impacts the perceived page load time. Avoid importing modules, making network requests, or doing complex logic. Just read from localStorage and set the attribute -- nothing more.

The prefers-contrast Media Feature

Related to prefers-color-scheme, the prefers-contrast media feature detects whether the user has requested increased or decreased contrast. This is particularly important for dark mode because dark themes sometimes reduce contrast to create a softer aesthetic, which can be a problem for users with low vision.

Using prefers-contrast

/* Default styles */
:root {
    --color-text-secondary: #64748b;
    --color-border: #e2e8f0;
}

/* High contrast preference */
@media (prefers-contrast: more) {
    :root {
        --color-text-secondary: #334155;
        --color-border: #475569;
    }

    /* Increase border visibility */
    .card {
        border-width: 2px;
    }

    /* Remove subtle backgrounds that reduce contrast */
    .subtle-bg {
        background: transparent;
    }

    /* Make focus indicators more prominent */
    :focus-visible {
        outline: 3px solid var(--color-accent);
        outline-offset: 2px;
    }
}

/* Combine with dark mode for high-contrast dark theme */
@media (prefers-color-scheme: dark) and (prefers-contrast: more) {
    :root {
        --color-text-primary: #ffffff;
        --color-text-secondary: #e2e8f0;
        --color-bg-primary: #000000;
        --color-border: #94a3b8;
    }
}

/* Low contrast preference (rare) */
@media (prefers-contrast: less) {
    :root {
        --color-text-secondary: #94a3b8;
        --color-border: #f1f5f9;
    }
}

/* No preference (default) */
@media (prefers-contrast: no-preference) {
    /* Standard contrast values apply */
}

Testing Dark Mode in DevTools

All modern browsers provide tools to simulate dark mode without changing your system settings. This makes testing fast and convenient during development.

Testing Dark Mode in Browser DevTools

/* Chrome DevTools:
   1. Open DevTools (F12 or Cmd+Opt+I)
   2. Click the three-dot menu (⋮) in DevTools toolbar
   3. Click "More tools" then "Rendering"
   4. Find "Emulate CSS media feature prefers-color-scheme"
   5. Select "prefers-color-scheme: dark" from the dropdown

   Alternative shortcut in Chrome:
   1. Open Command Menu (Cmd+Shift+P / Ctrl+Shift+P)
   2. Type "dark" or "Emulate CSS prefers-color-scheme: dark"
   3. Select the option to toggle dark mode */

/* Firefox DevTools:
   1. Open DevTools (F12)
   2. Click the "Toggle dark mode simulation" button
      in the Inspector toolbar (sun/moon icon)
   3. Or use the Settings panel under "Simulate" section */

/* Safari DevTools:
   1. Open Web Inspector (Cmd+Opt+I)
   2. Go to the Elements panel
   3. Click the appearance icon in the toolbar
   4. Toggle between light and dark mode */

/* Testing checklist for dark mode:
   - All text meets minimum contrast ratios (4.5:1 for body text)
   - Images look appropriate (not too bright/dark)
   - Borders and dividers are visible
   - Focus indicators are clearly visible
   - Form controls are styled correctly
   - Shadows provide adequate depth perception
   - No elements "disappear" against the dark background
   - Semantic colors (success, error, warning) remain distinguishable
   - No hardcoded colors bypass the token system */

Complete Dark Mode Implementation

Let us put everything together into a complete dark mode implementation that handles system preference, manual toggle, localStorage persistence, flash prevention, images, and accessibility.

Full Dark Mode CSS Architecture

/* ================================================
   COMPLETE DARK MODE IMPLEMENTATION
   ================================================ */

/* 1. Root color-scheme declaration */
:root {
    color-scheme: light dark;
}

/* 2. Light theme tokens (default) */
:root {
    /* Backgrounds */
    --bg-page: #ffffff;
    --bg-surface: #ffffff;
    --bg-surface-raised: #f8fafc;
    --bg-surface-sunken: #f1f5f9;
    --bg-overlay: rgba(15, 23, 42, 0.5);

    /* Text */
    --text-heading: #0f172a;
    --text-body: #334155;
    --text-muted: #64748b;
    --text-disabled: #94a3b8;
    --text-on-accent: #ffffff;

    /* Interactive */
    --interactive-primary: #2563eb;
    --interactive-primary-hover: #1d4ed8;
    --interactive-secondary: #64748b;
    --interactive-secondary-hover: #475569;

    /* Borders */
    --border-default: #e2e8f0;
    --border-strong: #cbd5e1;
    --border-focus: #2563eb;

    /* Feedback */
    --feedback-success: #16a34a;
    --feedback-success-subtle: #f0fdf4;
    --feedback-error: #dc2626;
    --feedback-error-subtle: #fef2f2;
    --feedback-warning: #d97706;
    --feedback-warning-subtle: #fffbeb;

    /* Elevation */
    --elevation-1: 0 1px 3px rgba(0, 0, 0, 0.06);
    --elevation-2: 0 4px 8px rgba(0, 0, 0, 0.08);
    --elevation-3: 0 8px 24px rgba(0, 0, 0, 0.12);

    /* Images */
    --img-brightness: 1;
    --img-contrast: 1;

    /* Transitions */
    --theme-transition: background-color 0.2s ease,
                        color 0.2s ease,
                        border-color 0.2s ease,
                        box-shadow 0.2s ease;
}

/* 3. System dark mode (no manual override) */
@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
        --bg-page: #0f172a;
        --bg-surface: #1e293b;
        --bg-surface-raised: #334155;
        --bg-surface-sunken: #0f172a;
        --bg-overlay: rgba(0, 0, 0, 0.7);

        --text-heading: #f1f5f9;
        --text-body: #cbd5e1;
        --text-muted: #94a3b8;
        --text-disabled: #475569;
        --text-on-accent: #ffffff;

        --interactive-primary: #3b82f6;
        --interactive-primary-hover: #60a5fa;
        --interactive-secondary: #94a3b8;
        --interactive-secondary-hover: #cbd5e1;

        --border-default: #334155;
        --border-strong: #475569;
        --border-focus: #60a5fa;

        --feedback-success: #4ade80;
        --feedback-success-subtle: #052e16;
        --feedback-error: #f87171;
        --feedback-error-subtle: #450a0a;
        --feedback-warning: #fbbf24;
        --feedback-warning-subtle: #422006;

        --elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
        --elevation-2: 0 4px 8px rgba(0, 0, 0, 0.35);
        --elevation-3: 0 8px 24px rgba(0, 0, 0, 0.5);

        --img-brightness: 0.9;
        --img-contrast: 1.05;
    }
}

/* 4. Manual dark theme override */
[data-theme="dark"] {
    --bg-page: #0f172a;
    --bg-surface: #1e293b;
    --bg-surface-raised: #334155;
    --bg-surface-sunken: #0f172a;
    --bg-overlay: rgba(0, 0, 0, 0.7);

    --text-heading: #f1f5f9;
    --text-body: #cbd5e1;
    --text-muted: #94a3b8;
    --text-disabled: #475569;
    --text-on-accent: #ffffff;

    --interactive-primary: #3b82f6;
    --interactive-primary-hover: #60a5fa;
    --interactive-secondary: #94a3b8;
    --interactive-secondary-hover: #cbd5e1;

    --border-default: #334155;
    --border-strong: #475569;
    --border-focus: #60a5fa;

    --feedback-success: #4ade80;
    --feedback-success-subtle: #052e16;
    --feedback-error: #f87171;
    --feedback-error-subtle: #450a0a;
    --feedback-warning: #fbbf24;
    --feedback-warning-subtle: #422006;

    --elevation-1: 0 1px 3px rgba(0, 0, 0, 0.3);
    --elevation-2: 0 4px 8px rgba(0, 0, 0, 0.35);
    --elevation-3: 0 8px 24px rgba(0, 0, 0, 0.5);

    --img-brightness: 0.9;
    --img-contrast: 1.05;

    color-scheme: dark;
}

/* 5. Apply tokens to base elements */
body {
    background-color: var(--bg-page);
    color: var(--text-body);
    transition: var(--theme-transition);
}

h1, h2, h3, h4, h5, h6 {
    color: var(--text-heading);
}

a {
    color: var(--interactive-primary);
}

a:hover {
    color: var(--interactive-primary-hover);
}

hr {
    border-color: var(--border-default);
}

/* 6. Image brightness adjustment */
img:not(.no-dim) {
    filter: brightness(var(--img-brightness)) contrast(var(--img-contrast));
    transition: filter 0.2s ease;
}

/* 7. Form control theming */
input, textarea, select {
    background-color: var(--bg-surface);
    color: var(--text-body);
    border: 1px solid var(--border-default);
}

input:focus, textarea:focus, select:focus {
    border-color: var(--border-focus);
    box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.15);
}

/* 8. High contrast support */
@media (prefers-contrast: more) {
    :root {
        --text-muted: #334155;
        --border-default: #475569;
    }
}

@media (prefers-color-scheme: dark) and (prefers-contrast: more) {
    :root:not([data-theme="light"]) {
        --text-body: #f1f5f9;
        --text-muted: #cbd5e1;
        --border-default: #64748b;
    }
}

/* 9. Theme toggle button styling */
.theme-toggle {
    position: relative;
    background: var(--bg-surface-raised);
    border: 1px solid var(--border-default);
    border-radius: 999px;
    padding: 0.5rem;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.5rem;
    height: 2.5rem;
    transition: var(--theme-transition);
}

.theme-toggle:hover {
    background: var(--bg-surface-raised);
    border-color: var(--border-strong);
}

/* Show sun icon in dark mode, moon icon in light mode */
.theme-icon-light {
    display: block;
}

.theme-icon-dark {
    display: none;
}

@media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) .theme-icon-light {
        display: none;
    }
    :root:not([data-theme="light"]) .theme-icon-dark {
        display: block;
    }
}

[data-theme="dark"] .theme-icon-light {
    display: none;
}

[data-theme="dark"] .theme-icon-dark {
    display: block;
}

[data-theme="light"] .theme-icon-light {
    display: block;
}

[data-theme="light"] .theme-icon-dark {
    display: none;
}

Complete JavaScript for Theme Management

/* Complete theme management script */

class ThemeManager {
    constructor() {
        this.STORAGE_KEY = 'user-theme';
        this.THEMES = ['light', 'dark', 'system'];

        // Listen for system preference changes
        this.systemQuery = window.matchMedia(
            '(prefers-color-scheme: dark)'
        );
        this.systemQuery.addEventListener('change', () => {
            if (this.getStored() === 'system') {
                this.apply('system');
            }
        });
    }

    getStored() {
        return localStorage.getItem(this.STORAGE_KEY) || 'system';
    }

    getSystemPreference() {
        return this.systemQuery.matches ? 'dark' : 'light';
    }

    getEffective() {
        const stored = this.getStored();
        return stored === 'system' ? this.getSystemPreference() : stored;
    }

    apply(theme) {
        const effective = theme === 'system'
            ? this.getSystemPreference()
            : theme;

        if (theme === 'system') {
            document.documentElement.removeAttribute('data-theme');
        } else {
            document.documentElement.setAttribute('data-theme', theme);
        }

        // Update meta tag
        const meta = document.querySelector('meta[name="color-scheme"]');
        if (meta) {
            meta.content = effective === 'dark' ? 'dark' : 'light';
        }

        // Dispatch event for other components
        window.dispatchEvent(new CustomEvent('themechange', {
            detail: { theme, effective }
        }));
    }

    set(theme) {
        localStorage.setItem(this.STORAGE_KEY, theme);
        this.apply(theme);
    }

    toggle() {
        const current = this.getStored();
        const idx = this.THEMES.indexOf(current);
        const next = this.THEMES[(idx + 1) % this.THEMES.length];
        this.set(next);
        return next;
    }

    init() {
        this.apply(this.getStored());
    }
}

// Initialize
const themeManager = new ThemeManager();
themeManager.init();

// Attach to toggle button
document.getElementById('theme-toggle')
    ?.addEventListener('click', () => {
        themeManager.toggle();
    });
Note: The theme manager above cycles through three states: light, dark, and system. The "system" state removes the data-theme attribute, allowing the CSS prefers-color-scheme media query to take effect. This three-state approach gives users full control: they can choose to follow their system preference or override it on a per-site basis. Some implementations simplify this to just two states (light and dark), which is also acceptable but less flexible.

Smooth Theme Transitions

Adding CSS transitions to your theme switching creates a polished, professional feel. However, you need to be careful about which properties you animate and when. Transitioning every property on every element can cause performance issues and visual artifacts.

Adding Smooth Theme Transitions

/* Only transition specific properties on specific elements */

/* Body and main containers */
body,
.nav,
.sidebar,
.card,
.modal {
    transition: background-color 0.3s ease,
                color 0.3s ease,
                border-color 0.3s ease;
}

/* Avoid transitioning EVERYTHING -- this is slow */
/* BAD: * { transition: all 0.3s ease; } */

/* Disable transitions during initial page load */
/* Add this class to <html> and remove it after load */
.no-transitions * {
    transition: none !important;
}

/* JavaScript to prevent flash and enable transitions:
   document.documentElement.classList.add('no-transitions');
   // ... apply theme ...
   requestAnimationFrame(() => {
       requestAnimationFrame(() => {
           document.documentElement.classList.remove('no-transitions');
       });
   });
*/

/* Alternative: use view-transition API for smoother switches */
/* (Modern browsers only)
   document.startViewTransition(() => {
       document.documentElement.setAttribute('data-theme', newTheme);
   });
*/

Exercise 1: Dark Mode Dashboard

Build a complete dashboard page with full dark mode support. Create a navigation bar at the top with a logo, navigation links, and a theme toggle button (sun/moon icon). Below it, add a sidebar with menu items and a main content area containing four stat cards and a data table. Define a comprehensive color token system with at least 15 design tokens covering backgrounds, text, borders, accents, semantic colors, and shadows. Implement the light and dark themes using CSS custom properties. Add a <meta name="color-scheme"> tag and an inline script in the head to prevent theme flash. Use JavaScript to implement a three-state toggle (light, dark, system) that persists the choice in localStorage. Make sure the theme toggle button icon switches appropriately between sun and moon. Ensure all text meets WCAG AA contrast ratios (4.5:1 minimum) in both themes by testing with your browser's DevTools contrast checker. Add smooth transitions between themes but disable them during page load.

Exercise 2: Dark Mode Portfolio Site

Create a personal portfolio page that showcases your dark mode skills. Include a hero section with a large heading, subtitle, and a background gradient that changes between themes. Add a project showcase section with project cards that each have an image, title, description, and technology tags. Create a skills section with progress bars that use different accent colors. Build a contact form with styled inputs that adapt to both themes. For the images, use the <picture> element with light and dark source variants for at least one image, and apply brightness/contrast adjustments to the others. Implement proper shadow handling where shadows become more pronounced in dark mode. Include a prefers-contrast: more media query that increases border widths and text contrast in both themes. Add a floating theme toggle button with a smooth animated transition between its sun and moon states using CSS keyframe animations. Persist the theme choice and prevent flash on reload. Finally, add a code snippet section that uses syntax-highlighted code blocks with different color palettes for light and dark mode.