How to Create a CSS-Only Dark Mode Toggle for Your Website

by | Jun 4, 2026 | Uncategorized | 0 comments

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
dark mode website design

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.

dark mode website design

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'));
}
dark mode website design

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:

  1. Chrome DevTools color picker shows the contrast ratio inline
  2. WebAIM Contrast Checker for batch testing color pairs
  3. axe DevTools extension for full-page automated audits
  4. 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 #000 backgrounds, 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.

Search Keywords

Recent Posts

Subscribe Now!