CSS Dark Mode and Color Schemes
Implement system-aware dark mode with prefers-color-scheme, custom property swaps, color-scheme, and user-toggleable themes.
Overview
Key Points
- •@media (prefers-color-scheme: dark) matches when the OS is in dark mode
- •color-scheme: light dark tells the browser to adapt scrollbars, inputs, and backgrounds natively
- •Token-swap architecture keeps components clean: they reference semantic tokens, not raw colors
- •data-theme="dark" on the <html> element enables JavaScript-driven theme toggling
- •color-scheme on individual elements scopes the native styling (e.g., a dark card inside a light page)
- •forced-colors: active is a separate media query for high-contrast mode
- •The prefers-contrast media query detects when users prefer higher contrast
- •Combining system preference with a user override requires careful localStorage and class management
Examples
Token-swap dark mode architecture
:root {
/* Light mode tokens */
--bg-page: #ffffff;
--bg-surface: #f8fafc;
--bg-elevated: #ffffff;
--border-subtle: #e5e7eb;
--text-primary: #0f172a;
--text-secondary:#6b7280;
--brand: #6366f1;
--brand-hover: #4f46e5;
color-scheme: light dark;
}
@media (prefers-color-scheme: dark) {
:root {
--bg-page: #0f172a;
--bg-surface: #1e293b;
--bg-elevated: #334155;
--border-subtle: #334155;
--text-primary: #f1f5f9;
--text-secondary:#94a3b8;
--brand: #818cf8;
--brand-hover: #a5b4fc;
}
}
/* Components only reference tokens */
.card {
background: var(--bg-elevated);
border: 1px solid var(--border-subtle);
color: var(--text-primary);
}Components never reference raw colors. Swapping tokens in dark mode automatically updates every component.
User-toggled theme with data attribute
/* System default */
:root {
--bg: #ffffff;
--text: #0f172a;
color-scheme: light;
}
/* Dark mode via OS */
@media (prefers-color-scheme: dark) {
:root {
--bg: #0f172a;
--text: #f1f5f9;
color-scheme: dark;
}
}
/* User override: light forced */
[data-theme="light"] {
--bg: #ffffff;
--text: #0f172a;
color-scheme: light;
}
/* User override: dark forced */
[data-theme="dark"] {
--bg: #0f172a;
--text: #f1f5f9;
color-scheme: dark;
}JavaScript sets data-theme on <html>. The data attribute has higher specificity in the cascade than the @media fallback, so it overrides the system preference.
Dark mode images and media
/* Invert logos and diagrams in dark mode */
@media (prefers-color-scheme: dark) {
.logo-img {
filter: invert(1) hue-rotate(180deg);
}
/* Reduce brightness for photos */
img:not([src*=".svg"]) {
filter: brightness(0.85);
}
}
/* Use the picture element for art direction */
/* HTML: <picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="logo-light.svg" alt="Logo">
</picture> */
/* Target dark mode in CSS images */
.hero {
background-image: url('hero-light.jpg');
}
@media (prefers-color-scheme: dark) {
.hero {
background-image: url('hero-dark.jpg');
}
}Respecting forced-colors (high contrast mode)
/* ButtonText, ButtonFace, etc. are system color keywords */
@media (forced-colors: active) {
.btn {
/* Let the OS control colors entirely */
background: ButtonFace;
color: ButtonText;
border: 2px solid ButtonText;
forced-color-adjust: auto;
}
/* Mark decorative elements to be ignored */
.decorative-icon {
forced-color-adjust: none;
opacity: 0; /* or display: none */
}
}forced-colors: active (Windows High Contrast Mode) overrides CSS colors with OS system colors. Use system color keywords to ensure your UI is still usable.
Common Mistakes
- Using raw color values in component styles instead of semantic tokens (makes dark mode changes scattered)
- Not setting color-scheme, causing native UI elements (scrollbars, inputs, select) to stay light in dark mode
- Overriding user theme preference with a hardcoded light theme without providing an escape option
- Forgetting that high-contrast mode (forced-colors) is separate from dark mode and needs its own consideration
Note
Use oklch() for your token values. Since oklch is perceptually uniform, you can derive good dark mode variants by adjusting lightness: a light-mode color at L=0.55 becomes the dark-mode text at L=0.80 and dark-mode background at L=0.14.
Exercises
Write CSS to style the HTML scaffold. The preview updates live — when it looks right, mark as done.
Completed: 0 / 2 exercises
Knowledge Check
Question 1 of 3