How to Implement Dark Mode on a Website with Pure CSS & JavaScript

Dark mode setup using only CSS variables and vanilla JavaScript. No frameworks, no libraries, with a fix for the white flash most tutorials ignore.

Dark mode stopped being a nice extra a while back. People expect it now, the same way they expect a site to work on mobile. If your site only has one bright white theme, you'll hear about it from at least one visitor browsing at midnight.

Dark mode using css and JavaScript

The good news is that you don't need a framework, a UI library, or any build tools to add it properly. CSS variables combined with a small amount of vanilla JavaScript handle the entire job. This guide builds a complete, working dark mode toggle, and also covers the part almost every tutorial skips: stopping the page from flashing white for a split second before the dark theme loads.

The Core Idea

Instead of writing two separate stylesheets or duplicating every color rule, you define your colors once as CSS variables, then swap the values depending on which theme is active. Your actual layout CSS never changes. Only the variable values change.

:root {
  --bg-color: #ffffff;
  --text-color: #1a1a1a;
  --card-bg: #f4f4f4;
  --border-color: #e0e0e0;
  --accent: #2563eb;
}

[data-theme="dark"] {
  --bg-color: #121212;
  --text-color: #e8e8e8;
  --card-bg: #1e1e1e;
  --border-color: #333333;
  --accent: #4f8cff;
}

The trick is the [data-theme="dark"] selector. When that attribute gets added to the html tag, every variable underneath it overrides the default ones in :root. Your CSS rules elsewhere just reference the variable, never the hardcoded color, so they automatically pick up whichever theme is active.

body {
  background-color: var(--bg-color);
  color: var(--text-color);
  transition: background-color 0.3s ease, color 0.3s ease;
}

.card {
  background-color: var(--card-bg);
  border: 1px solid var(--border-color);
}

a {
  color: var(--accent);
}

That small transition line matters more than it looks. Without it, switching themes feels like flipping a light switch with no warning. With it, the whole page eases between colors smoothly instead of snapping.

Building the Toggle Button

Here's a simple toggle button in the markup:

<button id="themeToggle" aria-label="Toggle dark mode">
  🌙
</button>

And the JavaScript that actually switches the theme:

const toggleBtn = document.getElementById('themeToggle');
const htmlEl = document.documentElement;

function setTheme(theme) {
  htmlEl.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
  toggleBtn.textContent = theme === 'dark' ? '☀️' : '🌙';
}

toggleBtn.addEventListener('click', () => {
  const current = htmlEl.getAttribute('data-theme');
  setTheme(current === 'dark' ? 'light' : 'dark');
});

This does three things every time someone clicks the button. It flips the data-theme attribute on the html element, which triggers the CSS variable swap. It saves the choice in localStorage, so the preference sticks around after the visitor leaves. And it updates the icon on the button itself so it reflects the current state instead of staying static.

Remembering the Choice on Page Reload

Saving the preference is only half the job. You also need to apply it the moment the page loads, before anything gets painted to the screen. This is where most tutorials fall short, and it's worth explaining why.

If you load the saved theme using a script placed at the bottom of the page (which is the usual advice for performance), the browser already rendered the page in light mode by the time your script runs. For a fraction of a second, dark mode users see a bright white flash before the page switches to dark. It's jarring, and on a slow connection or an older device, that flash can last long enough to actually notice and be annoyed by.

The fix is to apply the theme as early as possible, directly in the head, before the browser renders anything else.

<head>
  <script>
    (function() {
      const savedTheme = localStorage.getItem('theme');
      const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const theme = savedTheme || (systemPrefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
</head>

This tiny inline script runs immediately as the browser parses the head, well before the body and its content load. It checks three things in order: a previously saved preference, then the visitor's operating system setting, and finally falls back to light mode if neither exists. Because this runs before the page paints, there's no flash at all. The page simply loads already in the correct theme.

This pattern, sometimes called a "theme blocking script," is the actual fix professional sites use. It's a handful of lines, but it's the difference between a polished dark mode and one that looks broken on first impression.

Respecting the Visitor's System Preference

Beyond just checking the system preference once on load, you can also listen for changes, in case someone switches their OS theme while your site is already open in a tab.

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    setTheme(e.matches ? 'dark' : 'light');
  }
});

Notice the check for an existing saved preference. This means if someone manually picked a theme using your toggle, their choice always takes priority over the system setting. The automatic switch only kicks in for visitors who haven't made an explicit choice yet, which is the behavior people actually expect.

Handling Images and Icons in Dark Mode

Text and backgrounds are the easy part. Images, especially logos with transparent backgrounds, often look wrong once the background turns dark. A white logo with thin black outlines might vanish against a white background but also look strange floating on dark gray.

A common approach is swapping the image source based on the theme:

<img id="logo" src="logo-light.svg" alt="Site Logo">
function updateLogo(theme) {
  const logo = document.getElementById('logo');
  logo.src = theme === 'dark' ? 'logo-dark.svg' : 'logo-light.svg';
}

Call updateLogo() inside your setTheme function so it switches alongside everything else. For icons built with CSS (using mask or background images with currentColor-friendly SVGs), you can often skip this entirely and let the icon inherit the text color variable instead, which avoids maintaining two separate image files.

A Mistake That's Easy to Miss: Hardcoded Colors in Inline Styles

This setup works well until someone (often a different developer, or you six months later) adds an inline style like style="color: #333" directly on an element instead of using the CSS variable. That single hardcoded value won't respond to the theme change, and it'll sit there looking wrong in dark mode while everything around it switches correctly.

There's no clever fix for this beyond discipline. Treat your CSS variables as the only acceptable source of color in the project, and audit inline styles and third-party widgets (embedded forms, chat widgets, ad code) separately, since those often bring their own hardcoded colors that your variables can't touch.

Full Working Example

Here's everything combined into one minimal page so you can see how the pieces fit together:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <script>
    (function() {
      const savedTheme = localStorage.getItem('theme');
      const systemPrefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      const theme = savedTheme || (systemPrefersDark ? 'dark' : 'light');
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
  <style>
    :root {
      --bg-color: #ffffff;
      --text-color: #1a1a1a;
      --accent: #2563eb;
    }
    [data-theme="dark"] {
      --bg-color: #121212;
      --text-color: #e8e8e8;
      --accent: #4f8cff;
    }
    body {
      background-color: var(--bg-color);
      color: var(--text-color);
      transition: background-color 0.3s ease, color 0.3s ease;
      font-family: sans-serif;
      padding: 40px;
    }
    button {
      background: none;
      border: 1px solid var(--text-color);
      color: var(--text-color);
      padding: 8px 14px;
      border-radius: 6px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <button id="themeToggle">Toggle Theme</button>
  <p>This page remembers your theme choice and respects your system setting.</p>

  <script>
    const toggleBtn = document.getElementById('themeToggle');
    const htmlEl = document.documentElement;

    function setTheme(theme) {
      htmlEl.setAttribute('data-theme', theme);
      localStorage.setItem('theme', theme);
    }

    toggleBtn.addEventListener('click', () => {
      const current = htmlEl.getAttribute('data-theme');
      setTheme(current === 'dark' ? 'light' : 'dark');
    });
  </script>
</body>
</html>

Paste this into a blank HTML file and open it in a browser. Click the button, reload the page, and the theme holds. Change your OS theme and reload again without ever clicking the toggle, and the page follows your system setting until you make a manual choice.

Why This Approach Beats Reaching for a Library

There are npm packages and frameworks built specifically for theme switching, and for large applications with many components, that tooling can genuinely help. But for most websites, blogs, and small to mid sized projects, pulling in a dependency for something this small adds weight to your page for very little benefit.

The CSS variable and inline script combination shown here is roughly 30 lines total, has zero dependencies, loads instantly, and gives you complete control over the colors and behavior. There's nothing to update when a package releases a breaking change, and nothing to debug beyond your own code.

The Bottom Line

A solid dark mode implementation comes down to three things: define your colors as variables instead of hardcoding them, apply the saved theme before the page paints to avoid the white flash, and let the toggle update both the attribute and the stored preference together. Get those three right, and the rest is just picking colors that look good against each other.

It's a small feature, but it's one visitors notice immediately, in either direction. A dark mode that flashes white on load or leaves a logo looking broken says more about a site's polish than people might expect from something this minor.

Post a Comment