Color Mode

Light/Dark Theming with Custom Properties

Users expect websites to respect their system preferences. Operating systems have offered light and dark modes for years, and modern browsers expose these preferences to CSS through media queries. Implementing themes used to require JavaScript-heavy solutions with complex color management. Today, CSS custom properties combined with media queries and color functions make theming straightforward and maintainable.

This lesson explores how to build flexible theming systems using modern CSS. You will learn how prefers-color-scheme detects user preferences, how custom properties organize theme values, and how color-mix() generates theme variations programmatically. These techniques form the foundation for the theming system you will implement in your next project.

Understanding prefers-color-scheme

The prefers-color-scheme media query detects whether a user has requested a light or dark color theme through their operating system settings. This media query has three possible values: light, dark, and no-preference. When a user enables dark mode in their system settings, prefers-color-scheme: dark matches, allowing you to apply dark theme styles automatically.

Basic Usage

The simplest theming approach defines colors directly in media queries. Light mode styles serve as the default, and dark mode styles override them when the media query matches.


    /* Default light theme */
    body {
        background: white;
        color: oklch(0.2 0 0);
    }

    /* Dark theme when user preference matches */
    @media (prefers-color-scheme: dark) {
        body {
            background: oklch(0.15 0 0);
            color: oklch(0.95 0 0);
        }
    }

This approach works but becomes difficult to maintain as your color system grows. Every color needs to be defined twice, and changes require updating multiple locations. Custom properties solve this problem by centralizing theme values.

Detecting User Preference with JavaScript

Sometimes you need to know the user's preference in JavaScript, perhaps to store their choice or apply theme-specific logic. The matchMedia API provides this capability.


    /**
     * Check if user prefers dark mode
     * @returns {boolean} True if dark mode is preferred
     */
    const prefersDark = () => {
        return window.matchMedia('(prefers-color-scheme: dark)').matches;
    };

    /**
     * Listen for theme preference changes
     */
    const initThemeListener = () => {
        const darkModeQuery = window.matchMedia('(prefers-color-scheme: dark)');
        
        darkModeQuery.addEventListener('change', (event) => {
            if (event.matches) {
                console.log('User switched to dark mode');
            } else {
                console.log('User switched to light mode');
            }
        });
    };

Theming with Custom Properties

Custom properties (CSS variables) provide the foundation for maintainable theming. Instead of defining colors throughout your stylesheet, you define them once in custom properties and reference those properties everywhere. When the theme changes, you update the custom property values, and all references update automatically.

Organizing Theme Variables

A well-organized theme system separates colors into semantic layers. Base colors define your palette, and semantic colors assign meaning to those base colors based on their usage in the interface.


    :root {
        /* Base color palette */
        --blue-60: oklch(0.6 0.15 250);
        --blue-40: oklch(0.4 0.15 250);
        --gray-95: oklch(0.95 0 0);
        --gray-90: oklch(0.90 0 0);
        --gray-20: oklch(0.20 0 0);
        --gray-15: oklch(0.15 0 0);

        /* Semantic colors - light theme */
        --color-background: var(--gray-95);
        --color-surface: white;
        --color-text: var(--gray-20);
        --color-text-secondary: var(--gray-40);
        --color-primary: var(--blue-60);
        --color-border: var(--gray-90);
    }

    @media (prefers-color-scheme: dark) {
        :root {
            /* Semantic colors - dark theme */
            --color-background: var(--gray-15);
            --color-surface: var(--gray-20);
            --color-text: var(--gray-95);
            --color-text-secondary: var(--gray-70);
            --color-primary: var(--blue-40);
            --color-border: var(--gray-30);
        }
    }

This structure separates what colors exist from how they are used. The base palette remains constant, while semantic colors change between themes. Components reference semantic colors, making them theme-agnostic.

Using Theme Variables

Components reference semantic colors rather than specific color values. This keeps components independent of the theme system.


    .card {
        background: var(--color-surface);
        color: var(--color-text);
        border: 0.0625rem solid var(--color-border);
    }

    .button {
        background: var(--color-primary);
        color: white;
    }

    .text-muted {
        color: var(--color-text-secondary);
    }

These components automatically adapt to theme changes because they reference custom properties that update based on prefers-color-scheme. No JavaScript or additional classes are needed.

Generating Themes with color-mix()

The color-mix() function blends two colors together, creating new colors programmatically. This capability is powerful for theming because you can generate entire color palettes from a few base colors. Rather than manually defining every shade, you mix your base color with white or black to create lighter and darker variations.

Understanding color-mix() Syntax

The color-mix() function takes a color space, two colors, and optional percentages that control how much of each color to use. The syntax is color-mix(in colorspace, color1 percentage, color2 percentage).


    /* Mix 80% primary with 20% white */
    --color-primary-light: color-mix(in oklch, var(--color-primary) 80%, white);

    /* Mix 60% primary with 40% black */
    --color-primary-dark: color-mix(in oklch, var(--color-primary) 60%, black);

    /* Equal mix of two colors */
    --color-blend: color-mix(in oklch, var(--color-primary), var(--color-secondary));

Creating Systematic Color Scales

You can generate complete color scales from a single base color by mixing it with white and black in varying proportions. This creates a consistent, mathematical progression of shades.


    :root {
        /* Base brand color */
        --brand-base: oklch(0.5 0.15 250);

        /* Generate lighter shades by mixing with white */
        --brand-10: color-mix(in oklch, var(--brand-base) 10%, white);
        --brand-30: color-mix(in oklch, var(--brand-base) 30%, white);
        --brand-50: var(--brand-base);
        
        /* Generate darker shades by mixing with black */
        --brand-70: color-mix(in oklch, var(--brand-base) 70%, black);
        --brand-90: color-mix(in oklch, var(--brand-base) 90%, black);
    }

Interactive color-mix() Explorer

Experiment with color-mix() by adjusting the base color and mix percentages. Use your browser's developer tools (right-click and select "Inspect" or press F12) to see how the CSS custom properties change as you adjust the controls.

Light
30% mix
Base
100%
Dark
70% mix

Experiment with different base colors and mix percentages to see how color-mix() generates variations. Notice how:

Implementing Theme Toggles

While prefers-color-scheme respects system preferences, many users want manual control over website themes. A theme toggle allows users to override their system preference and choose the theme they prefer for your site specifically. This requires JavaScript to manage theme state and apply the appropriate theme class or attribute.

Theme Toggle Pattern

The common pattern uses a class on the document root element to control the theme. Light mode uses the default styles, and a .dark-theme class applies dark mode styles. This approach works alongside prefers-color-scheme to provide both automatic detection and manual override.


    :root {
        /* Light theme colors */
        --color-background: oklch(0.95 0 0);
        --color-text: oklch(0.2 0 0);
    }

    /* Dark theme overrides */
    .dark-theme {
        --color-background: oklch(0.15 0 0);
        --color-text: oklch(0.95 0 0);
    }

Theme Toggle Implementation

A complete theme toggle system needs to detect the initial preference, allow user override, and persist the choice across page loads.


    /**
     * Theme management system
     * Handles theme detection, toggling, and persistence
     */
    const themeManager = {
        /**
         * Get the stored theme preference from localStorage
         * @returns {string|null} The stored theme or null if none
         */
        getStoredTheme: () => {
            return localStorage.getItem('theme');
        },

        /**
         * Store theme preference in localStorage
         * @param {string} theme - The theme to store ('light' or 'dark')
         */
        setStoredTheme: (theme) => {
            localStorage.setItem('theme', theme);
        },

        /**
         * Get the user's system preference
         * @returns {string} 'dark' if dark mode preferred, 'light' otherwise
         */
        getSystemPreference: () => {
            const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
            return darkQuery.matches ? 'dark' : 'light';
        },

        /**
         * Determine which theme to use
         * Prioritizes stored preference, falls back to system preference
         * @returns {string} The theme to apply ('light' or 'dark')
         */
        getPreferredTheme: () => {
            const stored = themeManager.getStoredTheme();
            return stored || themeManager.getSystemPreference();
        },

        /**
         * Apply theme by toggling dark-theme class
         * @param {string} theme - The theme to apply ('light' or 'dark')
         */
        applyTheme: (theme) => {
            if (theme === 'dark') {
                document.documentElement.classList.add('dark-theme');
            } else {
                document.documentElement.classList.remove('dark-theme');
            }
        },

        /**
         * Toggle between light and dark themes
         * @returns {string} The newly active theme
         */
        toggleTheme: () => {
            const currentTheme = themeManager.getStoredTheme() || themeManager.getSystemPreference();
            const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
            
            themeManager.setStoredTheme(newTheme);
            themeManager.applyTheme(newTheme);
            
            return newTheme;
        },

        /**
         * Initialize theme system on page load
         */
        init: () => {
            const theme = themeManager.getPreferredTheme();
            themeManager.applyTheme(theme);

            const toggleButton = document.getElementById('theme-toggle');
            if (toggleButton) {
                toggleButton.addEventListener('click', () => {
                    const newTheme = themeManager.toggleTheme();
                    console.log(`Theme switched to: ${newTheme}`);
                });
            }
        }
    };

    themeManager.init();

Theme Toggle Button

The toggle button UI should clearly indicate the current theme and the action that will occur when clicked. Icons or text that represent the opposite theme work well because they show what will happen, not what currently is.


    <button id="theme-toggle" class="theme-toggle" aria-label="Toggle theme">
        <span class="light-icon">☀️</span>
        <span class="dark-icon">🌙</span>
    </button>

    .theme-toggle {
        background: var(--color-surface);
        border: 0.0625rem solid var(--color-border);
        border-radius: 0.25rem;
        padding: 0.5rem;
        cursor: pointer;
        font-size: 2rem;
    }

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

    .dark-theme .light-icon {
        display: inline;
    }

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

Complete Theming Example

This interactive example demonstrates a complete theming system. The page respects your system preference by default and includes a toggle button to override it. Open your browser's developer tools (right-click and select "Inspect" or press F12) to examine how the theme classes and custom properties change when you toggle the theme.

Theme Demo

Custom Properties in Action

This demo uses CSS custom properties to switch between themes. All colors reference semantic variables like --demo-bg, --demo-text, and --demo-primary. The theme toggle adds or removes the .demo-dark class, which updates these variables.

Why This Approach Works

Components reference semantic colors rather than specific values. When the theme changes, the custom property values update, and all components automatically reflect the new theme. No component-level changes are needed.

Toggle between light and dark themes to see how custom properties update. Use your browser's developer tools to inspect the custom property values and observe how they change when the .demo-dark class is added or removed.

Accessibility Considerations

Implementing themes requires attention to accessibility. Users rely on sufficient color contrast, consistent focus indicators, and predictable behavior regardless of theme.

Color Contrast Requirements

WCAG (Web Content Accessibility Guidelines) requires minimum contrast ratios between text and background colors. Normal text needs at least 4.5:1 contrast, while large text needs at least 3:1. These requirements apply to both light and dark themes.

Test your theme colors using browser developer tools or online contrast checkers. When generating color scales with color-mix(), verify that text colors maintain sufficient contrast with their backgrounds. Dark themes often require lighter text colors than you might initially expect to meet contrast requirements.

Focus Indicators

Focus indicators must remain visible in both themes. Users who navigate with keyboards rely on these indicators to know which element is currently focused. Ensure your focus styles use colors that work across themes.


    :focus-visible {
        outline: 0.125rem solid var(--color-primary);
        outline-offset: 0.125rem;
    }

Reduced Motion Preferences

Some users experience discomfort from animations and transitions. The prefers-reduced-motion media query detects this preference. When implementing theme transitions, respect this preference by disabling or reducing animations.


    /* Smooth theme transitions by default */
    * {
        transition: background-color 0.3s ease, color 0.3s ease;
    }

    /* Disable transitions for users who prefer reduced motion */
    @media (prefers-reduced-motion: reduce) {
        * {
            transition: none;
        }
    }

ARIA Attributes for Theme Toggles

Theme toggle buttons should include appropriate ARIA attributes to communicate their purpose and state to assistive technologies.


    <button 
        id="theme-toggle" 
        aria-label="Toggle between light and dark theme"
        aria-pressed="false">
        Toggle Theme
    </button>

Update the aria-pressed attribute when the theme changes to indicate the toggle's current state.

Best Practices for Theme Systems

Well-designed theme systems follow consistent patterns that make them maintainable and extensible.

Semantic Color Naming

Name colors based on their purpose, not their appearance. Use names like --color-text, --color-background, and --color-primary rather than --color-black, --color-white, or --color-blue. Semantic names remain meaningful across themes, while appearance-based names become confusing when a "blue" variable holds an orange value in dark mode.

Layer Your Color System

Organize colors in layers: base palette colors that never change, semantic colors that map base colors to purposes, and component-specific colors that reference semantic colors. This layering makes theme changes predictable and isolated.

Default to System Preferences

Respect prefers-color-scheme by default. Users who configured their system for dark mode expect websites to honor that choice. Provide a manual override, but make the system preference the default behavior when no override exists.

Persist User Choices

When users manually select a theme, store their preference in localStorage. Load this preference on subsequent visits rather than forcing them to toggle the theme every time they visit your site. Clear the stored preference if they change their system settings, allowing the system preference to take over again.

Test Both Themes

Develop with both themes visible. Do not design entirely in light mode and add dark mode as an afterthought. Components should look polished and functional in both themes from the start. Pay special attention to borders, shadows, and subtle UI elements that might disappear in one theme or the other.

Key Concepts Summary

Modern CSS provides powerful tools for implementing themes. The prefers-color-scheme media query detects user preferences automatically. Custom properties centralize theme values, making theme changes as simple as updating variable values. The color-mix() function generates color variations programmatically, allowing you to create entire palettes from base colors.

Complete theming systems combine automatic detection with manual override. Respect system preferences by default, provide a toggle for user control, and persist choices across visits. Pay attention to accessibility by maintaining color contrast, visible focus indicators, and respecting reduced motion preferences.

Organize your theme system in layers: base colors, semantic colors, and component colors. This structure keeps themes maintainable and makes adding new themes or updating existing ones straightforward. Test both themes throughout development rather than treating one as an afterthought.

Explore Further

To deepen your understanding of theming and color systems, explore these topics independently: