Dark Mode and Theming: prefers-color-scheme and a Theme Toggle
Build dark mode with CSS variables, follow the visitor's system theme with prefers-color-scheme, add a remembered toggle button, and design a good dark palette.
What you will learn
Dark mode is no longer a bonus feature. Many visitors have their phone or computer set to a dark theme and expect websites to follow. In this lesson you will build dark mode the right way, on top of the CSS variables from the last lesson: first by following the visitor’s system setting with prefers-color-scheme, then by adding a toggle button that remembers the choice, and finally by learning how to design a dark palette that is comfortable and readable.
A word about the JavaScript. The toggle button needs a little JavaScript. The JavaScript course explains every line later. For now, you can copy it and read the comments. The CSS is the important part of this lesson.
The HTML we will style
<header class="site-header">
<h1 class="site-title">Theme Lab</h1>
<button class="theme-toggle" id="theme-toggle" type="button">Switch theme</button>
</header>
<main class="page">
<article class="card">
<h2 class="card-title">Dark mode, step by step</h2>
<p class="card-text">This page reads all of its colors from CSS variables...</p>
<p class="card-meta">Posted today · 3 min read</p>
<a href="#more" class="card-link">Read more</a>
</article>
<form class="signup">
<label class="form-label" for="email">Email address</label>
<input class="form-input" id="email" type="email" placeholder="you@example.com">
<button class="btn" type="submit">Subscribe</button>
</form>
<blockquote class="quote">Good dark themes are designed, not just inverted.</blockquote>
<p class="note">Tip: set <code class="code">color-scheme</code> so form controls ...</p>
</main>
| Selector | Element | Role |
|---|---|---|
:root |
the <html> element |
holds the light colour variables, and the data-theme attribute |
:root[data-theme="dark"] |
the <html> element, when it has data-theme="dark" |
holds the dark colour variables |
.site-header, .site-title |
the <header> and its <h1> |
top bar |
.theme-toggle and #theme-toggle |
the <button> |
the class styles it, the id lets JavaScript find it |
.page |
the <main> |
centred content column |
.card, .card-title, .card-text, .card-meta, .card-link |
the <article> and its contents |
a surface with text and a link |
.signup, .form-label, .form-input, .btn |
the form, its label, field and button | a form that must also change colour |
.quote, .note, .code |
the <blockquote>, a <p> and a <code> |
extra text styles |
The idea: change the variables, not the components
Look at the practice page. Nothing in .card mentions “dark”. It just says background-color: var(--color-surface). Dark mode works by changing what --color-surface means, and every component updates automatically. That is why the semantic layer of variables from the last lesson (named by job, not by colour) matters so much.
Step 1: put every colour in a variable
Start with the light theme as your default. Every colour used by the page must live in a variable:
:root {
--color-bg: #f8fafc; /* the page background */
--color-surface: #ffffff; /* cards, header, panels */
--color-text: #0f172a; /* main text */
--color-muted: #475569; /* secondary text */
--color-border: #e2e8f0; /* lines and outlines */
--color-primary: #2563eb; /* links and main buttons */
--color-on-primary: #ffffff; /* text ON the primary colour */
}
Then write components using only the variables:
body {
background-color: var(--color-bg);
color: var(--color-text);
}
/* the <article class="card"> */
.card {
padding: 20px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
}
/* the <p class="card-meta"> */
.card-meta {
color: var(--color-muted);
font-size: 0.875rem;
}
/* the <a class="card-link"> */
.card-link {
color: var(--color-primary);
}
/* the <button class="btn"> in the form */
.btn {
padding: 10px 18px;
border: none;
border-radius: 6px;
background-color: var(--color-primary);
color: var(--color-on-primary);
font: inherit;
cursor: pointer;
}
The most important rule: do not hard-code colours in components (
color: #333orbackground: white). Every hard-coded colour is a place where dark mode will break.
Step 2: follow the system setting with prefers-color-scheme
Operating systems let people choose light or dark. Browsers expose that choice to CSS with the prefers-color-scheme media query:
@media (prefers-color-scheme: dark) {
:root {
--color-bg: #0b1120;
--color-surface: #111827;
--color-text: #e5e7eb;
--color-muted: #9ca3af;
--color-border: #1f2937;
--color-primary: #60a5fa;
--color-on-primary: #0b1120;
}
}
That is the entire dark mode. Visitors with a dark system theme get these values, everyone else gets the light ones, and not one component rule changed.
To test it without changing your operating system: in Chrome or Edge DevTools, open the three-dot menu, choose More tools, then Rendering, and use Emulate CSS media feature prefers-color-scheme. In Firefox, the Inspector toolbar has sun and moon buttons for the same job.
color-scheme: let the browser darken its own parts
Your variables recolour your elements. But the browser draws some things itself: form controls, scrollbars, the default page background and spelling-error underlines. Tell the browser which theme you support with the color-scheme property:
:root {
color-scheme: light dark; /* "I support both: follow the user's system" */
}
With that line, a dark-mode visitor automatically gets dark scrollbars and form controls. You can also set a single scheme (color-scheme: dark) when you switch themes manually, as you will do next. Some developers also add <meta name="color-scheme" content="light dark"> to the HTML <head>, so that the page is not a white flash before the CSS loads.
Step 3: a manual toggle with data-theme
Following the system is polite, but visitors also like a button. The idea is to put an attribute on the <html> element, such as data-theme="dark", and write the dark variables for that attribute:
/* light by default */
:root {
color-scheme: light;
--color-bg: #f8fafc;
/* ...the rest of the light values... */
}
/* dark when <html data-theme="dark"> */
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #0b1120;
--color-surface: #111827;
--color-text: #e5e7eb;
--color-muted: #9ca3af;
--color-border: #1f2937;
--color-primary: #60a5fa;
--color-on-primary: #0b1120;
}
:root[data-theme="dark"] is an attribute selector (from the selectors lesson) combined with :root. It matches the <html> element only while the attribute has that value, and it is more specific than plain :root, so its variables win.
Now JavaScript only has to set and remove that attribute. Here is the code in the practice editor, with a plain-English reading of each part:
const root = document.documentElement; // the <html> element
const button = document.getElementById('theme-toggle'); // the <button id="theme-toggle">
function applyTheme(theme) {
root.setAttribute('data-theme', theme); // <html data-theme="dark">
button.textContent = theme === 'dark' ? 'Switch to light' : 'Switch to dark';
try { localStorage.setItem('theme', theme); } catch (error) {} // remember it
}
button.addEventListener('click', function () { // when the button is clicked...
const now = root.getAttribute('data-theme');
applyTheme(now === 'dark' ? 'light' : 'dark'); // ...flip the theme
});
document.documentElementis the<html>element, where the attribute lives.getElementById('theme-toggle')finds the button by its id. This is where ids are useful: JavaScript needs one unique, reliable handle.setAttribute('data-theme', theme)is the line that switches the page. CSS does the rest.localStoragestores the choice in the visitor’s browser, so it survives a reload. It is wrapped intry/catchbecause storage can be blocked, and a blocked storage must not break the button.
Combining “follow the system” and “toggle”
The best experience is: follow the system by default, but let a manual choice win. This needs the dark values in two places, because the manual data-theme="dark" and the system preference are two different triggers:
/* 1. light values: the default */
:root {
color-scheme: light;
--color-bg: #f8fafc;
--color-text: #0f172a;
}
/* 2. the system is dark AND the visitor has not forced light */
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: #0b1120;
--color-text: #e5e7eb;
}
}
/* 3. the visitor forced dark */
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #0b1120;
--color-text: #e5e7eb;
}
Read it as three situations:
- No choice made, and the system is light: rule 1.
- No choice made, and the system is dark: rule 2 applies (
:not([data-theme="light"])is true because there is no attribute). - Visitor clicked “dark”: rule 3. If they click “light”, rule 2’s
:notfails, so the light values stay.
The drawback is that the dark values are written twice. A newer CSS function fixes that.
light-dark(): both values in one place
light-dark(lightValue, darkValue) picks a colour according to the element’s current color-scheme:
:root {
color-scheme: light dark; /* follow the system */
--color-bg: light-dark(#f8fafc, #0b1120);
--color-surface: light-dark(#ffffff, #111827);
--color-text: light-dark(#0f172a, #e5e7eb);
}
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"] { color-scheme: dark; }
Each variable now shows its light and dark colour side by side, and the manual toggle simply forces color-scheme one way. There is no duplication and no media query. light-dark() is supported in current browsers. If you must support older ones, use the three-rule pattern above.
Avoid the flash of the wrong theme
If the saved theme is applied by JavaScript after the page has drawn, a dark-mode visitor sees a brief white flash. The fix is a tiny inline script in the <head>, before your stylesheet, that sets the attribute immediately:
<head>
<meta charset="UTF-8">
<script>
try {
var saved = localStorage.getItem('theme');
if (saved) document.documentElement.setAttribute('data-theme', saved);
} catch (error) {}
</script>
<link rel="stylesheet" href="style.css">
</head>
Designing a good dark palette
A dark theme is not the light theme with colours inverted. A few principles make it comfortable:
- Do not use pure black and pure white.
#000backgrounds with#ffftext are harsh and cause glare. Use a very dark blue-grey background (#0b1120) and a soft off-white (#e5e7eb). - Use lighter surfaces for “higher” things. On light themes, depth comes from shadows. On dark themes, a card is a slightly lighter grey than the page (
#111827on#0b1120), because shadows are barely visible on dark backgrounds. - Lighten and soften your accent colours. A strong blue that looks good on white (
#2563eb) can vibrate on dark. Use a lighter shade (#60a5fa). That also means the text colour on the accent usually flips from white to dark, which is why we have--color-on-primary. - Keep contrast high enough. The 4.5 : 1 text rule applies in dark mode too. Check your pairs with the Color Converter’s contrast checker.
- Do not make muted text too dim. Grey on dark grey is a common accessibility failure.
- Keep the same meaning for colours. Errors should still look like errors, in a lighter red.
Images, shadows and borders
/* dim bright images slightly so they do not glare in dark mode */
:root[data-theme="dark"] img {
filter: brightness(0.9);
}
/* stronger shadows barely show on dark, so rely on borders instead */
.card {
border: 1px solid var(--color-border);
}
Logos and illustrations drawn for a white background can also need a dark-mode version, which you can swap with the HTML <picture> element and a prefers-color-scheme media attribute.
Form controls and the color-scheme property
Inputs are the classic dark mode trouble spot. Give them explicit variable colours and the correct color-scheme:
/* the <input class="form-input"> */
.form-input {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.form-input::placeholder {
color: var(--color-muted);
}
Putting it together
:root {
color-scheme: light;
--color-bg: #f8fafc;
--color-surface: #ffffff;
--color-text: #0f172a;
--color-muted: #475569;
--color-border: #e2e8f0;
--color-primary: #2563eb;
--color-on-primary: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
color-scheme: dark;
--color-bg: #0b1120;
--color-surface: #111827;
--color-text: #e5e7eb;
--color-muted: #9ca3af;
--color-border: #1f2937;
--color-primary: #60a5fa;
--color-on-primary: #0b1120;
}
}
:root[data-theme="dark"] {
color-scheme: dark;
--color-bg: #0b1120;
--color-surface: #111827;
--color-text: #e5e7eb;
--color-muted: #9ca3af;
--color-border: #1f2937;
--color-primary: #60a5fa;
--color-on-primary: #0b1120;
}
body {
margin: 0;
font-family: Arial, Helvetica, sans-serif;
background-color: var(--color-bg);
color: var(--color-text);
}
.site-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 20px;
background-color: var(--color-surface);
border-bottom: 1px solid var(--color-border);
}
.theme-toggle {
padding: 8px 14px;
border: 1px solid var(--color-border);
border-radius: 999px;
background-color: var(--color-bg);
color: var(--color-text);
font: inherit;
cursor: pointer;
}
.card {
padding: 20px;
margin-bottom: 20px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
}
.card-meta { color: var(--color-muted); font-size: 0.875rem; }
.card-link { color: var(--color-primary); }
.form-label { display: block; margin-bottom: 6px; font-weight: bold; }
.form-input {
padding: 10px 12px;
border: 1px solid var(--color-border);
border-radius: 6px;
background-color: var(--color-surface);
color: var(--color-text);
font: inherit;
}
.btn {
padding: 10px 18px;
border: none;
border-radius: 6px;
background-color: var(--color-primary);
color: var(--color-on-primary);
font: inherit;
cursor: pointer;
}
.quote {
margin: 20px 0;
padding-left: 16px;
border-left: 4px solid var(--color-primary);
color: var(--color-muted);
}
.code {
padding: 2px 6px;
border-radius: 4px;
background-color: var(--color-surface);
border: 1px solid var(--color-border);
}
Common mistakes
- Hard-coding colours in components (
color: #333), which stay dark on a dark background. - Only changing the background and text, and forgetting borders, inputs, placeholders and focus rings.
- Using pure black and white, which creates glare.
- Reusing the same accent colour in both themes, so it vibrates on dark or has too little contrast.
- Forgetting
color-scheme, so scrollbars and native controls stay light. - Writing the dark theme only for the toggle and ignoring
prefers-color-scheme, or the other way around. - Not remembering the visitor’s choice, so the page resets on every visit.
- A flash of the wrong theme, because the saved theme is applied late.
- Forgetting that images and logos designed for white backgrounds may look wrong on dark.
- Testing only one theme. Check every page and state (hover, focus, disabled, error) in both.
Practice
- Click Practice in Editor. Press the toggle button. Which parts change? Is there anything that does not change?
- Open the CSS tab and change
--color-primaryin the dark block to a light green. Does the button stay readable? - Add a new component, such as a
.alertbox, using only variables. Does it work in both themes without any extra rules? - Add a
--color-dangervariable with a light and a dark value, and use it for an error message. - Add the
@media (prefers-color-scheme: dark)block with:root:not([data-theme="light"]). Use DevTools to emulate a dark system theme and check that the page follows. - Rewrite the colour variables with
light-dark()andcolor-scheme: light dark. How many lines do you save? - Replace the pure
#000000and#ffffffin a dark theme with softer values, then compare the comfort. - Check the contrast of your muted text and your button text with the Color Converter’s contrast checker.
Recap
- Build dark mode on variables named by job. Components use only variables, and a theme is a different set of values.
@media (prefers-color-scheme: dark)follows the system setting automatically.color-schemetells the browser to render its own controls and scrollbars in the matching theme.- A toggle puts
data-theme="dark"or"light"on<html>. CSS reacts with:root[data-theme="dark"], and a little JavaScript sets the attribute and saves it inlocalStorage. - To follow the system but let the visitor override, combine the media query with
:root:not([data-theme="light"]), or uselight-dark()to avoid repeating values. - Set the saved theme early in the
<head>to avoid a flash. - Design dark palettes with soft colours, lighter surfaces for depth, adjusted accent colours and checked contrast.
- Next you will bring your interfaces to life with transitions and transforms.