Dark mode is no longer a nice-to-have: users expect a theme toggle on blogs, dashboards, and docs sites, and operating systems now advertise a preferred color scheme your site can respect automatically. Learning how to add a dark mode toggle to your website with JavaScript is a perfect beginner project because it combines CSS custom properties, a little DOM scripting, and localStorage persistence in one satisfying feature. This guide walks you through the complete implementation, from theming your CSS the right way to a toggle button that remembers the visitor’s choice.

In This Guide

The Plan: How the Toggle Will Work

Before writing code, understand the architecture. We will define all theme colors as CSS custom properties (variables) on the root element, with a [data-theme="dark"] override block. JavaScript will toggle that data-theme attribute on the <html> element when the button is clicked, save the choice in localStorage, and on page load, apply the saved choice or fall back to the operating system’s preference. This approach keeps a single source of truth for the theme and makes every element on the page switch instantly, because they all reference the variables.

Step 1: Theme Your CSS With Custom Properties

Finger about to click a moon-shaped dark mode toggle button on a website header at night

CSS custom properties let you define a value once and reuse it everywhere. Define your light theme defaults on :root, then override them under a dark-theme selector. Every component uses the variables, never hardcoded colors:

:root {
  --bg: #ffffff;
  --text: #1a1a1a;
  --card: #f5f5f5;
  --accent: #2563eb;
}

[data-theme="dark"] {
  --bg: #121212;
  --text: #e8e8e8;
  --card: #1e1e1e;
  --accent: #60a5fa;
}

body {
  background-color: var(--bg);
  color: var(--text);
}

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

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

The rule of thumb: if a color appears in your design, it should come from a variable. Audit your stylesheet for hardcoded hex values in backgrounds, text, borders, and shadows, and convert them. The MDN guide to CSS custom properties documents the full syntax, including fallback values like var(--bg, #fff). If you are still getting comfortable with selectors and the cascade, our guide to HTML semantic tags will tighten up the markup this CSS styles.

Step 2: Add the Toggle Button HTML

Place a button in your header or navigation. Use a real <button>, not a div, so keyboard users can operate it, and give it an accessible label plus an icon that updates with the theme:

<button id="theme-toggle" aria-label="Toggle dark mode">
  <span id="theme-icon">☾</span>
</button>

The moon character (☾) shows in light mode, suggesting “switch to dark”; we will swap it to a sun (☼) in dark mode. Style the button to match your header, a simple circular button with a border works well. Avoid emoji icons for a cleaner look; unicode symbols or inline SVG are sharper.

Step 3: Write the JavaScript Toggle

The core logic is short: read the current theme from the data-theme attribute, flip it, and update the icon. If you are new to DOM events, our guide to JavaScript addEventListener explains the event handling used here in detail.

const toggleButton = document.getElementById('theme-toggle');
const themeIcon = document.getElementById('theme-icon');

function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  themeIcon.textContent = theme === 'dark' ? '\u2600' : '\u263E';
  toggleButton.setAttribute('aria-label',
    theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
}

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

Setting the attribute on document.documentElement (the <html> element) is what activates the [data-theme="dark"] CSS block from step 1. Everything referencing the variables repaints immediately.

Step 4: Remember the Choice With localStorage

A toggle that forgets on every page load is annoying. Save the choice in localStorage whenever it changes, and read it back on load:

const STORAGE_KEY = 'site-theme';

function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  themeIcon.textContent = theme === 'dark' ? '\u2600' : '\u263E';
  localStorage.setItem(STORAGE_KEY, theme);
}

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

// Apply saved theme as early as possible
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
  setTheme(saved);
}

Wrap the localStorage calls in try/catch if you want to be robust in private-browsing modes where storage may throw. One subtlety: to avoid a flash of the wrong theme on load, run the “apply saved theme” snippet in a small script placed right after the opening <body> tag, or even in the <head>, so it executes before the page paints.

Step 5: Respect the OS Color Scheme by Default

First-time visitors have no saved preference, so defer to their operating system using the prefers-color-scheme media query. The MDN documentation for window.matchMedia covers the API used here:

function getPreferredTheme() {
  const saved = localStorage.getItem(STORAGE_KEY);
  if (saved) return saved; // explicit choice always wins
  return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
}

// On load:
setTheme(getPreferredTheme());

This gives you the ideal behavior matrix: a returning visitor gets their saved choice, a new visitor gets whatever their OS uses, and the toggle always lets anyone override. You can also listen for OS changes while the page is open, but only apply them when the user has not made an explicit choice, otherwise you would fight their toggle.

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

Step 6: Smooth Transitions and Accessibility

An instant snap between themes can feel jarring. Add a short transition on color properties:

body {
  transition: background-color 0.3s ease, color 0.3s ease;
}

Keep it subtle: 0.2 to 0.3 seconds. Respect users who prefer reduced motion by disabling the transition under prefers-reduced-motion:

@media (prefers-reduced-motion: reduce) {
  body {
    transition: none;
  }
}

Accessibility Checklist for Your Toggle

Dark mode is an accessibility feature for many users, so the toggle itself must be accessible: use a real button with an aria-label that describes the action, ensure the icon has sufficient contrast in both themes, make the button reachable and operable by keyboard (native buttons are, by default), and verify your dark palette meets WCAG contrast ratios. Dark gray text on black fails; aim for at least 4.5:1 for body text. A quick check with any online contrast checker on your variable pairs takes two minutes and prevents the most common dark-mode complaint.

Don’t Forget the Browser Chrome

Add <meta name="color-scheme" content="light dark"> to your head so the browser renders scrollbars, form controls, and other native UI in the matching scheme. Without it, you get a dark page with blinding white scrollbars and form fields.

Step 7: Sync the Theme Across Open Tabs

Here is a detail most tutorials skip: if a visitor has your site open in two tabs and flips the toggle in one, the other tab stays on the old theme until it is reloaded. The fix is the storage event, which fires in every other tab whenever localStorage changes:

window.addEventListener('storage', (event) => {
  if (event.key === 'theme') {
    applyTheme(event.newValue);
  }
});

Pair it with a small refactor: move the DOM-updating logic into an applyTheme(theme) function, and let both the click handler and the storage listener call it. The click handler saves to localStorage (which triggers the event in other tabs); the listener just applies what changed. This is five lines of code, and it is the difference between a toggle that feels solid and one that feels buggy on real-world multi-tab browsing.

Edge Cases Worth Handling

Two more small touches separate a good implementation from a great one. First, guard against corrupted storage values: if someone (or an extension) writes theme=banana into localStorage, your code should fall back to the OS preference instead of rendering an unstyled page. A simple ['light', 'dark'].includes(saved) check before applying covers it. Second, remember that localStorage can throw in private browsing modes with storage disabled — wrap your reads and writes in try/catch so the toggle degrades to a session-only preference instead of crashing the script. Neither case is common, but handling them is what makes the feature feel production-ready rather than demo-ready.

The Complete Copy-Paste Example

Here is everything assembled into one minimal page you can save as dark-mode-demo.html and open in a browser:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="color-scheme" content="light dark">
<title>Dark Mode Demo</title>
<style>
  :root {
    --bg: #ffffff; --text: #1a1a1a; --card: #f5f5f5; --accent: #2563eb;
  }
  [data-theme="dark"] {
    --bg: #121212; --text: #e8e8e8; --card: #1e1e1e; --accent: #60a5fa;
  }
  body {
    background: var(--bg); color: var(--text);
    font-family: system-ui, sans-serif; margin: 0; padding: 2rem;
    transition: background-color .3s ease, color .3s ease;
  }
  @media (prefers-reduced-motion: reduce) {
    body { transition: none; }
  }
  #theme-toggle {
    font-size: 1.25rem; padding: .5rem .75rem; cursor: pointer;
    border: 1px solid var(--text); border-radius: 999px;
    background: var(--card); color: var(--text);
  }
</style>
</head>
<body>
<script>
  // Apply theme before first paint to avoid a flash
  (function () {
    var saved = null;
    try { saved = localStorage.getItem('site-theme'); } catch (e) {}
    var theme = saved ||
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

<button id="theme-toggle" aria-label="Toggle dark mode">
  <span id="theme-icon">☾</span>
</button>
<h1>Dark Mode Demo</h1>
<p>Click the button to switch themes. Your choice is remembered.</p>

<script>
  var toggleButton = document.getElementById('theme-toggle');
  var themeIcon = document.getElementById('theme-icon');

  function setTheme(theme) {
    document.documentElement.setAttribute('data-theme', theme);
    themeIcon.textContent = theme === 'dark' ? '\u2600' : '\u263E';
    toggleButton.setAttribute('aria-label',
      theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode');
    try { localStorage.setItem('site-theme', theme); } catch (e) {}
  }

  // Sync icon with the theme applied before paint
  setTheme(document.documentElement.getAttribute('data-theme') || 'light');

  toggleButton.addEventListener('click', function () {
    var current = document.documentElement.getAttribute('data-theme');
    setTheme(current === 'dark' ? 'light' : 'dark');
  });
</script>
</body>
</html>

One detail worth noting: the early script sets the attribute before paint, then the later script calls setTheme once to sync the icon and label without disturbing the stored value. Test the demo by toggling, reloading, and changing your OS theme with no saved choice to see each behavior.

Frequently Asked Questions

Should I use a class or a data attribute for the theme?

Both work. A data-theme attribute on <html> is slightly more expressive and keeps theme state visible in devtools; a class like .dark on <body> is equally fine. Pick one and be consistent.

How do I avoid the flash of the wrong theme on page load?

Run a tiny inline script in the <head> (or right after <body> opens) that reads localStorage and sets the theme attribute before the browser paints, exactly as in the complete example above.

Can I have more than two themes?

Yes. Extend the pattern: data-theme="sepia" with another override block, and cycle through an array of theme names in the toggle instead of flipping a boolean.

Does dark mode affect SEO?

No. Search engines do not care about your color scheme. Just make sure content is identical in both themes and text contrast stays readable.

How do I theme third-party widgets or iframes?

You generally cannot restyle cross-origin iframes. For widgets you control, expose the same CSS variables; for third-party embeds, check whether the provider offers a dark theme parameter (many comment and video embeds do).

Why use CSS variables instead of two stylesheets?

Variables keep one stylesheet, switch instantly without reloading CSS, and make it trivial to add more themes later. Two stylesheets mean duplicated rules that drift out of sync.

Key Takeaways

  • Define every theme color as a CSS custom property, with a [data-theme="dark"] override block.
  • Toggle the data-theme attribute on <html> with a small JavaScript click handler.
  • Persist the choice in localStorage so it survives page loads.
  • Default to the OS preference via prefers-color-scheme when no choice is saved.
  • Apply the saved theme before first paint to avoid a flash of the wrong theme.
  • Add the color-scheme meta tag, smooth transitions, and a contrast-checked palette for a polished result.

Conclusion

Learning how to add a dark mode toggle to your website with JavaScript boils down to three layers working together: CSS variables that describe both themes, a tiny script that flips a data-theme attribute, and localStorage plus prefers-color-scheme so the right theme shows up automatically. It is a small feature with an outsized payoff in user satisfaction, and the techniques transfer directly to any theming or preference system you build later. Once your toggle is live, level up the surrounding layout with our guide on how to center a div in CSS, and make sure the whole page adapts gracefully with CSS media queries for responsive design.

Leave a Reply

Your email address will not be published. Required fields are marked *