How to build a dark mode toggle

A step-by-step tutorial for a dark mode that follows your visitor's device setting, adds a toggle button that saves their pick in localStorage, and never flashes the wrong theme on load.

A toggle switch between a sunny light side and a starry dark side, switched to the moon

Lots of people keep their phone or laptop in dark mode, and a bright white page can feel like someone flipped on the kitchen light at midnight. In this tutorial you'll build a dark mode that follows the visitor's device setting by default, plus a toggle button that lets them pick light or dark and remembers it on their next visit. You'll also add a button to go back to the device setting, stop the page from flashing the wrong theme while it loads, and make form controls and scrollbars match. It's plain HTML, CSS and a little JavaScript, no framework needed.

Set up your colors as custom properties

Start by giving every color a name. Instead of writing #ffffff all over your stylesheet, you write var(--bg), and the actual value lives in one place. Switching themes then means swapping a handful of variables, not hunting down every rule.

The light colors go on :root. The dark colors show up twice on purpose. The media query block uses them when the device prefers dark and the visitor hasn't forced light. The data-theme block uses them when the visitor has picked dark themselves. That :not([data-theme="light"]) part is what lets a saved choice beat the device setting.

CSS
:root {
  color-scheme: light;
  --bg: #ffffff;
  --surface: #f3f3f7;
  --text: #1f1f24;
  --muted: #5c5c66;
  --accent: #c2255c;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --bg: #16161d;
    --surface: #23232c;
    --text: #ededf2;
    --muted: #a6a6b3;
    --accent: #f783ac;
  }
}

:root[data-theme="dark"] {
  color-scheme: dark;
  --bg: #16161d;
  --surface: #23232c;
  --text: #ededf2;
  --muted: #a6a6b3;
  --accent: #f783ac;
}

body {
  margin: 0;
  font-family: system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
}

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

Let color-scheme handle the browser bits

Your custom properties only change things you style. Checkboxes, text inputs, select menus and scrollbars are drawn by the browser, and they'd stay light on a dark page. The color-scheme property tells the browser which scheme you're using, so it can draw those built-in parts to match.

That's why each block in the CSS above sets color-scheme next to the colors. It flips at the same moment the variables do, so you don't end up with a glowing white scrollbar on a dark page.

Add a real toggle button

Use a real button element so it works with a keyboard and screen readers for free. The toggle keeps the same label, Dark mode, and uses aria-pressed to say whether it's on. Screen readers announce it as a toggle button that's pressed or not pressed, which is clearer than a label that keeps changing.

The second button clears the saved choice and hands control back to the device. You can style the pressed state with an attribute selector, so the look and the accessible state never drift apart.

HTML
<div class="theme-controls">
  <button type="button" class="theme-toggle" aria-pressed="false">
    Dark mode
  </button>
  <button type="button" class="theme-reset">
    Use device setting
  </button>
</div>
CSS
.theme-toggle
.theme-reset {
  font: inherit;
  padding: 0.5rem 1rem;
  border: 2px solid var(--accent);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
}

.theme-toggle[aria-pressed="true"] {
  background: var(--accent);
  color: var(--bg);
}

.theme-toggle:focus-visible
.theme-reset:focus-visible {
  outline: 3px solid var(--accent);
  outline-offset: 3px;
}

Save the choice and follow the device

The script works out the current theme in one small function. If data-theme is set, that wins. If not, it asks matchMedia whether the device prefers dark. Clicking the toggle flips to the other theme, puts it on the html element and saves it in localStorage under the key theme.

Clicking Use device setting removes both the attribute and the saved value. Now the CSS media query is in charge again. The change listener on darkQuery keeps aria-pressed honest when someone switches their OS appearance while the page is open. The storage calls sit in try blocks because some browsers block localStorage in strict privacy modes, and a theme button shouldn't break your page.

TSX
const root = document.documentElement;
const toggle = document.querySelector('.theme-toggle');
const reset = document.querySelector('.theme-reset');
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');

function currentTheme() {
  return root.dataset.theme || (darkQuery.matches ? 'dark' : 'light');
}

function updateButton() {
  toggle.setAttribute('aria-pressed', String(currentTheme() === 'dark'));
}

function saveTheme(value) {
  try {
    if (value) {
      localStorage.setItem('theme', value);
    } else {
      localStorage.removeItem('theme');
    }
  } catch (e) {}
}

toggle.addEventListener('click', () => {
  const next = currentTheme() === 'dark' ? 'light' : 'dark';
  root.dataset.theme = next;
  saveTheme(next);
  updateButton();
});

reset.addEventListener('click', () => {
  delete root.dataset.theme;
  saveTheme(null);
  updateButton();
});

darkQuery.addEventListener('change', updateButton);

updateButton();

Stop the flash of the wrong theme

If you only load the saved theme in a script at the bottom of the page, the browser paints the default theme first and then switches. On a slow connection that looks like a bright blink. The fix is a tiny inline script in the head, placed before your stylesheet, that sets data-theme before anything is drawn.

Keep it tiny and inline, not in a separate file, because the browser has to run it before it paints. It only reads the saved value and sets the attribute. Everything else can stay in your main script at the end of the body.

HTML
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>Dark mode demo</title>
  <script>
    (function () {
      try {
        var saved = localStorage.getItem('theme');
        if (saved === 'light' || saved === 'dark') {
          document.documentElement.dataset.theme = saved;
        }
      } catch (e) {}
    })();
  </script>
  <link rel="stylesheet" href="styles.css">
</head>

Try the shorter light-dark() version

Modern browsers also support a light-dark() CSS function. You give it two colors, and it picks one based on the active color-scheme. With color-scheme set to light dark on :root, it follows the device, and your data-theme rules only need to change color-scheme.

MDN marks light-dark() as Baseline 2024, newly available, so older browsers won't understand it. If you need to support them, stick with the custom property version above. The toggle script works the same with either one.

CSS
:root {
  color-scheme: light dark;
  --bg: light-dark(#ffffff, #16161d);
  --text: light-dark(#1f1f24, #ededf2);
}

:root[data-theme="light"] {
  color-scheme: light;
}

:root[data-theme="dark"] {
  color-scheme: dark;
}

One tip before you ship it: test with a saved choice and without one. Pick dark, reload, and the page should open dark with no flash. Then click Use device setting, switch your OS appearance, and the page and the button's pressed state should follow along without a reload. If you ever rename the storage key, change it in both the head script and the main script, because a mismatch means the saved choice quietly stops loading.