Blog / Coding tips

Dark Mode Toggle With JavaScript and localStorage

Cover image for Dark Mode Toggle With JavaScript and localStorage

To build a dark mode toggle with JavaScript and localStorage, define your colours as CSS variables, override them under :root[data-theme="dark"], and have a button switch the data-theme attribute on the <html> element and save the choice with localStorage.setItem("theme", ...). The key detail is a tiny script in the <head> that reads the saved theme, or falls back to the system setting with prefers-color-scheme, before the page is painted, so there's no white flash on reload. The complete, tested code is below.

My portfolio has a dark theme by default, and the first version of my light/dark switch had the classic bug: every page load flashed white for a split second before the dark colours kicked in. This version fixes that. I tested it in headless Chrome twice, once with the system set to light and once set to dark, clicking the toggle and reloading the page each time.

How the dark mode toggle works

  1. All colours in the CSS come from custom properties (variables) such as --bg and --text.
  2. A second block of CSS redefines those variables when <html data-theme="dark"> is set.
  3. A small inline script in the <head> picks the theme: the saved choice if there is one, otherwise the operating system's setting.
  4. The button flips the attribute and saves the new value in localStorage, so it's remembered next time.

Step 1: the HTML with the no-flash script

<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <meta name="color-scheme" content="light dark">
  <title>Dark mode toggle</title>
  <script>
    // Runs before the page is painted, so there is no white flash
    (function () {
      let theme = null;
      try { theme = localStorage.getItem("theme"); } catch (e) {}
      if (theme !== "light" && theme !== "dark") {
        theme = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
      }
      document.documentElement.dataset.theme = theme;
    })();
  </script>
  <link rel="stylesheet" href="theme.css">
</head>
<body>
  <header>
    <button id="theme-toggle" type="button" aria-pressed="false">Dark mode</button>
  </header>
  <main>
    <h1>My portfolio</h1>
    <p>Click the button to switch themes. Your choice is remembered.</p>
  </main>
  <script src="theme.js"></script>
</body>
</html>

The inline script is deliberately not in an external file or at the bottom of the page. Scripts in the <head> run before the browser paints the body, so the right data-theme is already set when the stylesheet is applied. If you moved this code into theme.js at the end of the body, the page would draw in the default theme first and then switch, which is the flash.

Two more details are worth pointing out:

  • The try/catch around localStorage: some browsers block storage entirely in strict privacy modes, and reading it then throws an error. Without the try, the whole script would stop and no theme would be set.
  • The check for "light" or "dark": anything else in storage (an old value, or something typed into DevTools) is ignored, and the system setting is used instead.

The <meta name="color-scheme"> tag tells the browser the page supports both, so things like scrollbars and form controls can match. The <header> and <main> elements are there for structure; I explain why in HTML semantic tags explained with examples.

Step 2: the CSS variables

:root {
  --bg: #ffffff;
  --text: #1a1d24;
  --accent: #4b3fd6;
}

:root[data-theme="dark"] {
  --bg: #10131c;
  --text: #e4e8f2;
  --accent: #8b7cff;
}

body {
  margin: 0;
  padding: 2rem;
  background: var(--bg);
  color: var(--text);
  font-family: system-ui, sans-serif;
  transition: background-color 0.2s, color 0.2s;
}

#theme-toggle {
  border: 2px solid var(--accent);
  background: transparent;
  color: var(--text);
  padding: 0.5rem 1rem;
  border-radius: 999px;
  cursor: pointer;
}

Because every colour on the page comes from a variable, switching themes is just a matter of changing four or five values in one place. No class needs to be added to every element. As your site grows, add variables for borders, cards and links, and never hard-code a colour in a component. The transition makes the switch fade gently instead of snapping.

Step 3: the toggle button in JavaScript

// theme.js: the toggle button
const root = document.documentElement;
const button = document.getElementById("theme-toggle");

function applyTheme(theme) {
  root.dataset.theme = theme;
  button.setAttribute("aria-pressed", String(theme === "dark"));
}

function saveTheme(theme) {
  try {
    localStorage.setItem("theme", theme);
  } catch (e) {
    // Storage blocked: the toggle still works, it just won't be remembered
  }
}

applyTheme(root.dataset.theme);

button.addEventListener("click", () => {
  const next = root.dataset.theme === "dark" ? "light" : "dark";
  applyTheme(next);
  saveTheme(next);
});

The button uses aria-pressed so that screen readers announce it as a toggle that's either on or off. The label stays "Dark mode" and the pressed state says whether it's active. That's clearer than changing the text to "Light mode", where a screen reader user can't tell if the label describes the current state or the action.

Saving also has a try/catch. If storage is full or blocked, the theme still changes for this page; it just isn't remembered. I used the same defensive approach when saving data in my habit tracker with JavaScript and localStorage.

Testing it: toggle, reload, and system preference

I loaded the page in headless Chrome inside a small test harness, recorded the state, clicked the button, recorded it again, reloaded, and recorded it a third time. I ran it once with Chrome's colour scheme set to light and once set to dark:

# System set to light
first visit: data-theme=light, aria-pressed=false, background=rgb(255, 255, 255), saved=null
after click: data-theme=dark, aria-pressed=true, background=rgb(16, 19, 28), saved=dark
after reload: data-theme=dark, aria-pressed=true, background=rgb(16, 19, 28), saved=dark
# System set to dark
first visit: data-theme=dark, aria-pressed=true, background=rgb(16, 19, 28), saved=null
after click: data-theme=light, aria-pressed=false, background=rgb(255, 255, 255), saved=light
after reload: data-theme=light, aria-pressed=false, background=rgb(255, 255, 255), saved=light

Everything behaved as expected:

  • On the first visit nothing was saved (saved=null), so the page followed the system setting.
  • Clicking switched the background colour and saved the new theme.
  • After a reload the saved choice won over the system setting, with the right colours straight away.

Following the system theme until the user chooses

The code above follows the operating system only on a first visit. If you'd like the page to change live when someone's laptop switches to dark mode at sunset, listen for changes to the media query and only react if the user hasn't picked a theme yet:

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

I couldn't flip the operating system setting halfway through a headless test, so I checked this listener's logic in Node with a fake matchMedia and localStorage:

no saved choice, system goes dark -> dark
saved 'light', system goes dark   -> light

You can read more about the media query on MDN's prefers-color-scheme page. To give users a way back to "use my system setting", add a third option that calls localStorage.removeItem("theme").

Common dark mode problems

  • White flash on load: the theme is being set by a script at the bottom of the page or after DOMContentLoaded. Move the reading part into an inline script in the <head>.
  • Theme forgotten on other pages: localStorage is shared per origin, so www.example.com and example.com have separate storage. Pick one and redirect the other.
  • Images or logos look wrong: use a <picture> element with a media="(prefers-color-scheme: dark)" source, or give dark logos a light background.
  • Next.js or React hydration warnings: the server can't know the theme, so set it in the inline head script and add suppressHydrationWarning to the <html> element.

Can you do a dark mode toggle with CSS only?

Partly. A hidden checkbox plus the :has() selector, such as :root:has(#dark:checked), can switch the variables without any JavaScript. But CSS can't remember anything, so the choice is lost on reload. If remembering matters, and for a toggle it usually does, you need the few lines of JavaScript above.

FAQ

How do I save dark mode in localStorage?

When the user clicks the toggle, call localStorage.setItem("theme", "dark") (or "light"). On page load, read it back with localStorage.getItem("theme") and set the theme before the page is painted.

How do I stop dark mode flashing on page load?

Put a small inline script in the <head>, before your stylesheet, that sets data-theme on the <html> element. Because it runs before the body is drawn, the first paint already uses the right colours.

How do I detect if the user prefers dark mode in JavaScript?

Use window.matchMedia("(prefers-color-scheme: dark)").matches. It's true when the operating system or browser is set to dark.

Should I use localStorage or cookies for the theme?

localStorage is simpler and isn't sent with every request. Use a cookie only if your server needs to render the right theme in the HTML itself, for example in some server-rendered frameworks.

// note

How to read this note.

This is a learning note from studying the web. It is one small topic, written so I can remember it. It is not a course and not a claim that I have finished the subject.

If a sentence is wrong, say so from the contact page and name this title. Drafts never appear here. Related notes, when they exist, are other published posts, and the same sample rule applies to each of them.

Related notes

How to Work Out Your UK Degree Classification
Coding tips

How to Work Out Your UK Degree Classification

Work out a First, 2:1 or 2:2 from module marks: credit-weighted averages, 30:70 or 1:2 year weightings, rounding at 69.5 and the mark you need.

October 4, 2026 · 10 min read