Dark Mode with prefers-color-scheme
Lesson 32 โข Advanced Track
What You'll Learn
- Detect system dark mode with the prefers-color-scheme media query
- Build a manual three-way toggle (System / Light / Dark)
- Structure CSS variables for seamless light/dark switching
- Persist user theme preference with localStorage
- Handle images, shadows, and icons in dark mode
๐ก Real-World Analogy
Dark mode is like automatic headlights โ they turn on when it gets dark (system preference). But you can also flip the switch manually (user toggle). A well-built dark mode supports both, and remembers your choice so you don't have to switch every time you get in the car.
Understanding Dark Mode
The prefers-color-scheme media query reads the operating system's light/dark setting. It's supported in all modern browsers (Chrome, Firefox, Safari, Edge) and requires no JavaScript. When the user changes their OS appearance, the browser immediately applies the matching CSS.
The best strategy combines three layers: (1) default light values in :root, (2) dark overrides inside @media (prefers-color-scheme: dark), and (3) an optional [data-theme] attribute selector for manual user control that overrides both.
CSS Variables Strategy
| Variable | Light Value | Dark Value | Purpose |
|---|---|---|---|
| --bg | #ffffff | #121212 | Page background |
| --surface | #f5f5f5 | #1e1e1e | Card/panel background |
| --text | #1a1a1a | #e0e0e0 | Primary text |
| --primary | #1976D2 | #64B5F6 | Brand colour (lighter in dark) |
| --border | #e0e0e0 | #333333 | Dividers and borders |
| --shadow | rgba(0,0,0,0.08) | rgba(0,0,0,0.4) | Box shadows (stronger in dark) |
1. Basic prefers-color-scheme Detection
<!DOCTYPE html>
<html>
<head><style>
:root {
--bg: #ffffff; --surface: #f5f5f5; --text: #1a1a1a;
--text-muted: #666; --primary: #1976D2; --border: #e0e0e0;
--shadow: 0 2px 8px rgba(0,0,0,0.08);
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #121212; --surface: #1e1e1e; --text: #e0e0e0;
--text-muted: #999; --primary: #64B5F6; --border: #333;
--shadow: 0 2px 8px rgba(0,0,0,0.4);
}
}
body { font-family: system-ui, sans-serif; background: var(--bg);
... How variable cascading works: CSS custom properties follow the normal cascade. When @media (prefers-color-scheme: dark) activates, it re-declares the same variables on :root. Every element using var(--bg) instantly reads the new value โ no class toggling needed.
2. Manual Three-Way Toggle
<!DOCTYPE html>
<html>
<head><style>
:root {
--bg: #ffffff; --surface: #f5f5f5; --text: #1a1a1a;
--text-muted: #666; --primary: #1976D2; --border: #e0e0e0;
}
@media (prefers-color-scheme: dark) {
:root { --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --text-muted: #999; --primary: #64B5F6; --border: #333; }
}
[data-theme="dark"] { --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --text-muted: #999; --primary: #64B5F6; --border: #333; }
[data-theme="light"] { --bg: #ffffff; --
...Step-by-Step: data-theme Override
Why does [data-theme] override @media? Attribute selectors like [data-theme="dark"] have higher specificity than :root inside a media query. So when a user clicks "Dark", the attribute wins over the system preference โ giving users explicit control.
The "System" button removes the attribute entirely, letting the media query take over again.
3. Saving User Preference (localStorage)
<!DOCTYPE html>
<html>
<head><style>
:root { --bg: #fff; --surface: #f5f5f5; --text: #1a1a1a; --muted: #666; --primary: #1976D2; --border: #e0e0e0; }
[data-theme="dark"] { --bg: #121212; --surface: #1e1e1e; --text: #e0e0e0; --muted: #999; --primary: #64B5F6; --border: #333; }
body { font-family: system-ui; background: var(--bg); color: var(--text); padding: 24px; transition: background 0.3s; }
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 24px
...Images & Media in Dark Mode
Bright images can be jarring on dark backgrounds. Use filter: brightness(0.85) to subtly dim photos. For logos on transparent backgrounds, filter: invert(1) flips black to white. SVG icons should use currentColor for automatic colour inheritance.
Shadows need adjustment too โ on dark surfaces, shadows must be darker and more opaque to create visible depth contrast.
4. Adaptive Images & Shadows
<!DOCTYPE html>
<html>
<head><style>
:root { --bg: #fff; --text: #1a1a1a; --muted: #666; --primary: #1976D2; --surface: #f5f5f5; --border: #e0e0e0; --shadow: 0 4px 12px rgba(0,0,0,0.1); --img-filter: none; }
[data-theme="dark"] { --bg: #121212; --text: #e0e0e0; --muted: #999; --primary: #64B5F6; --surface: #1e1e1e; --border: #333; --shadow: 0 4px 12px rgba(0,0,0,0.5); --img-filter: brightness(0.85); }
body { font-family: system-ui; background: var(--bg); color: var(--text); padding: 24px; transi
...When to Use This
- Every modern website: Users expect dark mode. It reduces eye strain in low light and can save battery on OLED screens.
- Content-heavy apps: Reading apps, documentation sites, dashboards benefit most from dark mode.
- Branding consistency: Define your brand colours for both light and dark, don't let the browser guess.
Common Mistakes
- Using pure black (#000000) โ Pure black creates too much contrast against white text. Use #121212 or #1a1a2e for comfortable dark backgrounds.
- Forgetting box-shadows โ Light-mode shadows (rgba(0,0,0,0.08)) are invisible on dark backgrounds. Increase opacity to 0.3-0.5.
- Not testing transparent PNG logos โ A black logo on a transparent background disappears against a dark surface. Provide light/dark logo variants or use
filter: invert(1). - Flash of wrong theme (FOWT) โ If you read localStorage after page render, users see a flash of light mode. Set
data-themein a blocking<script>in the<head>. - Hardcoding colours instead of variables โ Every colour that changes between modes must be a CSS variable. Using
color: #333directly means it won't adapt. - Ignoring focus ring visibility โ Default focus rings may be invisible on dark backgrounds. Test keyboard navigation in both modes.
๐ Lesson Complete
- โ
prefers-color-scheme: darkdetects OS dark mode without JavaScript - โ CSS variables make theme switching a simple value override
- โ
[data-theme]attribute enables manual user control with higher specificity - โ Three-way toggle: System / Light / Dark gives users full control
- โ Save preference in localStorage and apply before render to avoid flash
- โ
Dim images with
brightness(0.85), flip logos withinvert(1) - โ Increase shadow opacity on dark surfaces for visible depth
- โ Always test both modes including focus rings and form elements
Sign up for free to track which lessons you've completed and get learning reminders.