Dark mode is no longer a nice-to-have feature on modern websites. It’s something visitors expect, especially those browsing late at night or working long hours in front of a screen. The good news? You don’t need a heavy framework or a complex JavaScript library to add it. With CSS custom properties and just a few lines of JavaScript for the switch, you can build a smooth, accessible dark mode toggle in under an hour.
In this tutorial, the team at FatCow Web Design walks you through the exact approach we use on client projects in 2026. We’ll cover palette selection, image handling, persistence with localStorage, and how to make sure your contrast ratios pass WCAG checks.
Why Use CSS Custom Properties for Dark Mode?
Before the days of CSS variables, theme switching meant maintaining two full stylesheets or overriding hundreds of selectors. CSS custom properties changed everything. You define your colors once at the :root level, then swap them based on a single attribute on the html or body tag.
Benefits of this approach:
- Single source of truth for your color tokens
- Instant theme switching without page reload
- Works alongside the new
light-dark()CSS function for fallbacks - Easy to extend with a third theme (sepia, high contrast, etc.)
- Tiny JavaScript footprint, usually under 30 lines

Step 1: Choose a Color Palette That Works in Both Modes
Picking dark mode colors isn’t just inverting black and white. Pure black on pure white causes eye strain and looks harsh on OLED screens. Here’s a balanced palette we recommend as a starting point:
| Token | Light Mode | Dark Mode | Usage |
|---|---|---|---|
| –bg | #ffffff | #121417 | Page background |
| –surface | #f5f5f7 | #1c1f24 | Cards, panels |
| –text | #1a1a1a | #e8e8ea | Body text |
| –muted | #666666 | #9aa0a6 | Secondary text |
| –accent | #0066cc | #4d9fff | Links, buttons |
| –border | #e0e0e0 | #2d3138 | Dividers |
Step 2: Set Up the HTML Structure
Add a simple toggle button to your header. We use a button element with proper ARIA labels for accessibility:
<button id="theme-toggle" aria-label="Toggle dark mode" aria-pressed="false">
<span class="icon-sun">☀</span>
<span class="icon-moon">☾</span>
</button>
Step 3: Define Your CSS Custom Properties
This is where the magic happens. Declare your light theme on :root, then override the values when data-theme="dark" is present:
:root {
--bg: #ffffff;
--surface: #f5f5f7;
--text: #1a1a1a;
--muted: #666666;
--accent: #0066cc;
--border: #e0e0e0;
color-scheme: light;
}
[data-theme="dark"] {
--bg: #121417;
--surface: #1c1f24;
--text: #e8e8ea;
--muted: #9aa0a6;
--accent: #4d9fff;
--border: #2d3138;
color-scheme: dark;
}
body {
background: var(--bg);
color: var(--text);
transition: background 0.25s ease, color 0.25s ease;
}
a { color: var(--accent); }
.card {
background: var(--surface);
border: 1px solid var(--border);
}
Notice the color-scheme property. It tells the browser to render native UI elements (scrollbars, form inputs) in the matching theme. Most tutorials skip this, but it’s important for a polished result.

Step 4: Respect the User’s System Preference
Before any JavaScript runs, check what the operating system prefers:
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #121417;
--surface: #1c1f24;
--text: #e8e8ea;
--muted: #9aa0a6;
--accent: #4d9fff;
--border: #2d3138;
color-scheme: dark;
}
}
This means the site will load in dark mode automatically for users whose OS is set that way, while still allowing them to override it with the toggle.
Step 5: Add the JavaScript Toggle
Here is the minimal JavaScript needed to flip the theme and persist the choice:
const toggle = document.getElementById('theme-toggle');
const root = document.documentElement;
const saved = localStorage.getItem('theme');
if (saved) root.setAttribute('data-theme', saved);
toggle.addEventListener('click', () => {
const current = root.getAttribute('data-theme')
|| (matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
const next = current === 'dark' ? 'light' : 'dark';
root.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
toggle.setAttribute('aria-pressed', next === 'dark');
});
To prevent the dreaded flash of wrong theme on page load, place this snippet inline in the <head>, before any stylesheets render:
<script>
(function() {
const t = localStorage.getItem('theme');
if (t) document.documentElement.setAttribute('data-theme', t);
})();
</script>
Step 6: Handle Images Properly
Images often look out of place when the theme changes. There are three techniques we use depending on the case:
Technique 1: Reduce brightness on dark mode
[data-theme="dark"] img {
filter: brightness(0.85) contrast(1.1);
}
Technique 2: Swap images with the picture element
<picture>
<source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
<img src="logo-light.svg" alt="Company logo">
</picture>
Technique 3: Use the new light-dark() function
Supported in all modern browsers as of 2026, this is the cleanest way to handle two-tone backgrounds:
.banner {
background-image: light-dark(url('hero-light.jpg'), url('hero-dark.jpg'));
}

Step 7: Verify Accessibility Contrast Ratios
WCAG 2.2 requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text and UI components. Dark mode often fails here because designers pick muted grays that look stylish but become unreadable.
Tools we use to verify every palette:
- Chrome DevTools color picker shows the contrast ratio inline
- WebAIM Contrast Checker for batch testing color pairs
- axe DevTools extension for full-page automated audits
- Lighthouse in the Accessibility tab
Quick reference for our recommended palette above:
| Pair | Ratio | WCAG |
|---|---|---|
| #e8e8ea on #121417 | 15.8:1 | AAA |
| #9aa0a6 on #121417 | 7.2:1 | AAA |
| #4d9fff on #121417 | 6.1:1 | AA |
Common Mistakes to Avoid
- Using pure
#000backgrounds, which cause halation on OLED screens - Forgetting
color-scheme, leaving form inputs and scrollbars in the wrong palette - Skipping the inline head script, causing the flash of wrong theme
- Hardcoding colors in components instead of using your tokens
- Ignoring focus states, which often disappear in dark mode
Final Thoughts
A well-built dark mode is one of the small details that separates a professional website from a hobby project. With CSS custom properties, the new light-dark() function, and a tiny script for persistence, you can ship it today without bloating your bundle.
If you’d rather have our team handle theming, performance, and accessibility on your next project, get in touch with FatCow Web Design. We build fast, accessible, modern websites that look great in any light.
FAQ
Can I implement a dark mode toggle with pure CSS, no JavaScript?
Yes, using the :has() selector with a checkbox or radio buttons. However, this approach can’t persist the user’s choice across page reloads, which is why most production sites use a tiny JavaScript snippet for localStorage.
What is the difference between prefers-color-scheme and a manual toggle?
The prefers-color-scheme media query reads the user’s operating system setting. A manual toggle lets visitors override that preference for your site specifically. The best practice is to combine both, using the system preference as the default.
Does dark mode improve performance or battery life?
On OLED and AMOLED displays, dark pixels consume significantly less power, so dark mode can extend battery life by 10 to 30 percent depending on usage. On LCD screens the difference is negligible.
Should I use Tailwind’s dark variant or custom properties?
If your project already uses Tailwind, the dark: variant is convenient. For framework-agnostic projects or design systems shared across stacks, CSS custom properties are more portable and easier to theme dynamically.
How do I prevent the flash of incorrect theme on page load?
Place a small inline script in the <head> that reads localStorage and sets the data-theme attribute on <html> before the body renders. This runs synchronously and avoids any visible flicker.
