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.
Experiment with different base colors and mix percentages to see how color-mix() generates variations. Notice how:
- Lower percentages create more dramatic color shifts (more white or black mixed in)
- Higher percentages stay closer to the base color
- The oklch color space maintains perceptual consistency across the scale
- You can generate an entire theme palette from a single brand color
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:
-
Investigate the oklch color space and why it produces more perceptually uniform color scales than RGB or HSL when using
color-mix(). - Study color theory and how it applies to dark mode design. Learn why colors need different saturation and brightness levels in dark themes compared to light themes.
-
Explore advanced color functions like
color-contrast()andrelative color syntaxthat provide even more control over color generation. - Research how major websites and applications implement theming. Examine their color systems, toggle mechanisms, and how they handle theme transitions.
-
Learn about other user preference media queries like
prefers-contrastandprefers-color-schemethat help create more accessible experiences.