Explore how to implement dark and light modes in web design using CSS. Learn the benefits, challenges, and practical examples to enhance user experience.

A dark mode toggle that forgets your choice on reload is worse than no toggle at all. The fix is straightforward: let the OS preference decide the first theme, let the user override it, and persist that override so the page doesn’t reset every time. Before you ship the colors, run them through the contrast checker so your dark mode light mode CSS setup stays readable in both directions.
A typical implementation fails in one of two ways.
First, it ignores the operating system entirely and hard-codes light mode until the user finds a toggle. That means someone with macOS, Windows, iOS, or Android already set to dark mode still gets blasted with a light UI on first load.
Second, it adds a toggle but treats it like temporary UI state. Imagine a docs site with a header switch: the user selects dark mode, reads one page, reloads, and the site flips back to light. That’s not a preference system. That’s a checkbox with amnesia.
There’s also a subtler failure: the flash of the wrong theme on load. If your HTML renders in light mode first and JavaScript switches to dark mode after the page has already painted, users see a quick flash every time. On a fast connection it’s annoying. On a slower device it’s obvious.
A production-ready approach needs four pieces working together:
prefers-color-scheme as the default fallback.localStorage.Put your colors behind variables first. That gives you one place to swap values and keeps components clean.
Use the exact slate/navy palette below, including color-scheme in both blocks:
:root {
color-scheme: light;
--bg: #f8fafc;
--surface: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
}
[data-theme='dark'] {
color-scheme: dark;
--bg: #0f172a;
--surface: #1e293b;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
}color-scheme tells the browser to adapt native UI elements like scrollbars, form inputs, and select dropdowns to match the active theme.
Now apply those variables to the page shell:
html {
background-color: var(--bg);
color: var(--text);
}
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background-color: var(--bg);
color: var(--text);
}
.site-header,
.site-footer {
border-bottom: 1px solid var(--border);
background: var(--surface);
}
.site-footer {
border-top: 1px solid var(--border);
border-bottom: 0;
}
p,
small,
label {
color: var(--text-muted);
}
a {
color: var(--text);
}If you’re expanding this into a fuller design system, a palette builder helps generate related accents, and our gradient generator is useful if your surfaces or hero areas use subtle theme-aware backgrounds.
The media query should act as your fallback, not your only theme mechanism. The key detail is the selector: only apply the dark variables when no manual theme override exists.
Use this block exactly:
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--bg: #0f172a;
--surface: #1e293b;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
}
}That gives you the right interaction model:
html has no data-theme, the OS preference wins.html has data-theme="light" or data-theme="dark", the explicit attribute wins.:root.That distinction matters because it keeps prefers-color-scheme as a sensible fallback instead of something your toggle has to fight against.
Use a real checkbox so you get keyboard support and form semantics for free, then expose switch semantics with ARIA.
Here’s the required HTML:
<label class="switch" aria-label="Toggle colour scheme">
<input
type="checkbox"
id="theme-toggle"
role="switch"
aria-checked="false"
>
<span class="slider"></span>
</label>And here’s a complete switch style you can drop in:
.switch {
position: relative;
display: inline-flex;
align-items: center;
width: 3.5rem;
height: 2rem;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
}
.slider {
position: relative;
width: 100%;
height: 100%;
border-radius: 999px;
background-color: var(--border);
border: 1px solid var(--border);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.slider::before {
content: '';
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
background-color: var(--surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
transition: transform 0.2s ease, background-color 0.2s ease;
}
.switch input:checked + .slider {
background-color: #475569;
border-color: #475569;
}
.switch input:checked + .slider::before {
transform: translateX(1.5rem);
}
.switch input:focus-visible + .slider {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}The important implementation detail is that aria-checked must be updated in JavaScript when the toggle changes. The HTML sets the initial structure; the script will keep the ARIA state synchronized with the actual checked state.
This is the part most implementations miss.
If you wait until your main bundle loads to set the theme, the browser paints the wrong colors first. The fix is to run a tiny inline script before the page is painted.
Place this verbatim before </head>:
<!-- Place before </head> to set theme before first paint -->
<script>
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>Why this works: it runs synchronously during document parsing, reads localStorage, falls back to prefers-color-scheme, and sets data-theme on <html> before the first paint. That prevents the wrong-theme flash.
Now wire up the toggle itself. The script below does three jobs:
data-theme when the user changes the switch.localStorage and updates aria-checked.<script>
const toggle = document.getElementById('theme-toggle');
// Sync toggle state to match current data-theme on load
const currentTheme = document.documentElement.getAttribute('data-theme');
toggle.checked = currentTheme === 'dark';
toggle.setAttribute('aria-checked', currentTheme === 'dark');
toggle.addEventListener('change', function () {
const theme = this.checked ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
this.setAttribute('aria-checked', this.checked);
});
</script>That gives you the full preference cascade explicitly:
localStorage takes precedence if the user has chosen a theme before.prefers-color-scheme decides.If you want the complete example in one runnable file, use this:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dark mode and light mode with CSS</title>
<style>
:root {
color-scheme: light;
--bg: #f8fafc;
--surface: #ffffff;
--text: #0f172a;
--text-muted: #64748b;
--border: #e2e8f0;
--shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
[data-theme='dark'] {
color-scheme: dark;
--bg: #0f172a;
--surface: #1e293b;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
--shadow: 0 10px 30px rgba(2, 6, 23, 0.45);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
color-scheme: dark;
--bg: #0f172a;
--surface: #1e293b;
--text: #f8fafc;
--text-muted: #94a3b8;
--border: #334155;
--shadow: 0 10px 30px rgba(2, 6, 23, 0.45);
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
padding: 2rem;
font-family: Inter, system-ui, sans-serif;
background: var(--bg);
color: var(--text);
transition: background-color 0.2s ease, color 0.2s ease;
}
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
}
.switch {
position: relative;
display: inline-flex;
align-items: center;
width: 3.5rem;
height: 2rem;
cursor: pointer;
}
.switch input {
position: absolute;
opacity: 0;
inset: 0;
margin: 0;
}
.slider {
position: relative;
width: 100%;
height: 100%;
border-radius: 999px;
background-color: var(--border);
border: 1px solid var(--border);
transition: background-color 0.2s ease, border-color 0.2s ease;
}
.slider::before {
content: '';
position: absolute;
top: 0.1875rem;
left: 0.1875rem;
width: 1.375rem;
height: 1.375rem;
border-radius: 50%;
background-color: var(--surface);
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
transition: transform 0.2s ease, background-color 0.2s ease;
}
.switch input:checked + .slider {
background-color: #475569;
border-color: #475569;
}
.switch input:checked + .slider::before {
transform: translateX(1.5rem);
}
.switch input:focus-visible + .slider {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.card {
max-width: 42rem;
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 1rem;
background: var(--surface);
box-shadow: var(--shadow);
}
.card h2 {
margin-top: 0;
color: var(--text);
}
.card p {
color: var(--text-muted);
line-height: 1.6;
}
</style>
<!-- Place before </head> to set theme before first paint -->
<script>
(function () {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})();
</script>
</head>
<body>
<div class="toolbar">
<h1>Theme demo</h1>
<label class="switch" aria-label="Toggle colour scheme">
<input
type="checkbox"
id="theme-toggle"
role="switch"
aria-checked="false"
>
<span class="slider"></span>
</label>
</div>
<article class="card">
<h2>Theme-aware card</h2>
<p>
This component uses the same tokens in both modes, with no duplicated selectors.
</p>
</article>
<script>
const toggle = document.getElementById('theme-toggle');
// Sync toggle state to match current data-theme on load
const currentTheme = document.documentElement.getAttribute('data-theme');
toggle.checked = currentTheme === 'dark';
toggle.setAttribute('aria-checked', currentTheme === 'dark');
toggle.addEventListener('change', function () {
const theme = this.checked ? 'dark' : 'light';
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem('theme', theme);
this.setAttribute('aria-checked', this.checked);
});
</script>
</body>
</html>Once the tokens exist, your components should consume them directly.
Here’s a card with a theme-aware shadow:
.card {
padding: 1.5rem;
border: 1px solid var(--border);
border-radius: 1rem;
background-color: var(--surface);
color: var(--text);
box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
}
.card p {
color: var(--text-muted);
}
[data-theme='dark'] .card {
box-shadow: 0 10px 30px rgba(2, 6, 23, 0.45);
}And the matching HTML:
<article class="card">
<h2>Release notes</h2>
<p>Dark mode now respects OS settings, persists your choice, and avoids theme flicker on reload.</p>
</article>You can also promote the shadow to a variable if you want fewer per-component overrides:
:root {
--shadow-md: 0 10px 30px rgba(15, 23, 42, 0.08);
}
[data-theme='dark'] {
--shadow-md: 0 10px 30px rgba(2, 6, 23, 0.45);
}
.card,
.modal,
.dropdown {
box-shadow: var(--shadow-md);
}That pattern scales better across a component library.
One note on assets: SVG icons that use currentColor usually adapt automatically because they inherit color. Bitmap icons do not; they may need filter: invert() in one theme, or separate light/dark image assets if brand fidelity matters.
For polishing those component details, a box-shadow builder is handy for dialing in layered shadows, and a shade and tint tool helps generate consistent hover, border, and muted variants around your base theme colors.
Persisting a theme in localStorage is simple and effective, but it changes the behavior of prefers-color-scheme: once the user manually selects a theme, that explicit choice should keep winning until they change it again. That’s usually correct, but it also means a user who switches their OS theme later won’t see your site follow along unless you provide a way to “reset to system” instead of forcing only light or dark.
The FOUC fix relies on an inline script in the document head. Some teams try to move every script out of the head for performance or CSP reasons, but this is one of the few cases where inline and early is the right call. If you use a strict Content Security Policy, you may need a nonce or hash for that snippet. Don’t push it to the footer just to make the HTML look cleaner; that reintroduces the flash you were trying to eliminate.
Shadows, borders, and muted text usually need separate tuning in dark mode. Reusing the same opacity values from light mode often makes surfaces feel muddy or too low-contrast. That’s why the card example changes shadow color rather than assuming the same RGBA works everywhere. Verify actual pairings instead of eyeballing them, especially for secondary text and low-emphasis UI.
Testing matters more than the code volume here. Use three checks every time:
localStorage in DevTools, reload, and confirm the fallback still follows the OS preference correctly.The reliable cascade is localStorage > prefers-color-scheme > default light, and once you wire it that way the feature stops feeling fragile. You end up with a theme system that respects the OS, honors manual override, persists across reloads, and avoids the wrong-theme flash on first paint; before shipping, validate your foreground/background pairs with the contrast checker and build out the rest of your tokens with the Palette Generator.