Building a theme toggle with vanilla JavaScript

If you have ever added a theme switch to a site, you already know the two hard parts are not the button. They are remembering what the visitor chose and painting the right colours before the first frame. This walkthrough builds the whole thing from scratch — no framework, no dependency, about forty lines of code in total.

Introduction

There are good reasons to offer a dark theme beyond fashion. It reduces glare for people reading at night, it helps a subset of readers with light sensitivity, and on displays with self-lit pixels it genuinely saves power. Enough products ship one now that its absence reads as an oversight.

There is also a bad reason, which is shipping a toggle that does not persist. A switch that forgets your choice on the next page is worse than no switch at all, because it teaches people that the control does not work. Persistence is the feature; the button is just the surface.

We will build it in four steps: a colour system driven by custom properties, a class on the root element that flips between the two sets, a script that applies the stored choice before the browser paints, and a button that keeps everything in sync. Every step is plain HTML, CSS and JavaScript.

Setting up the project

Start with a page skeleton. The only thing worth noting is that the toggle lives in the header and carries an aria-pressed attribute — the state has to be exposed to assistive technology, not only to the eye.

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="stylesheet" href="/styles.css" />
    <title>Theme toggle</title>
  </head>
  <body>
    <header class="bar">
      <span class="brand">Field notes</span>
      <button id="theme-toggle" type="button" aria-pressed="false">Dark theme</button>
    </header>
    <main class="page">
      <h1>Reading, comfortably</h1>
      <p>Switch the theme and reload — the choice survives.</p>
    </main>
    <script src="/theme.js"></script>
  </body>
</html>

Next, the colours. The trick that keeps this maintainable is to never write a colour on a component. Components reference variables; the variables change with the theme. Two blocks of custom properties are enough for a small site.

Notice that the default block sits on :root and the override sits on .dark. That ordering matters: the class only has to redefine the handful of values that actually differ, and everything downstream updates for free.

Add a color-scheme declaration as well. It is a one-line way to tell the browser to render form controls, scrollbars and the default canvas in the matching mode, which removes a whole category of “why is this input still white” bugs.

:root {
  color-scheme: light;
  --surface: #ffffff;
  --surface-muted: #f4f4f5;
  --text: #18181b;
  --text-muted: #71717a;
  --border: #e4e4e7;
}

.dark {
  color-scheme: dark;
  --surface: #0b0b0f;
  --surface-muted: #17171c;
  --text: #fafafa;
  --text-muted: #a1a1aa;
  --border: #27272a;
}

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

.bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 1rem 1.5rem;
  border-bottom: 1px solid var(--border);
  background: var(--surface-muted);
}

Now the part everybody gets wrong the first time: the flash. If the theme is applied after the stylesheets and the body have rendered, the page paints in the default scheme and then snaps to the stored one. The fix is a tiny blocking script in the <head>, before any styles are linked, that sets the class synchronously.

<script>
  (() => {
    let dark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    try {
      const stored = localStorage.getItem('theme');
      if (stored === 'light' || stored === 'dark') dark = stored === 'dark';
    } catch {
      /* storage blocked — fall back to the system preference */
    }
    document.documentElement.classList.toggle('dark', dark);
  })();
</script>

Three details are doing the work there. The system preference is the default, an explicit stored choice overrides it, and the whole thing is wrapped in a try because private browsing modes can throw on the first localStorage access.

Implementing the toggle

With the class already correct on first paint, the button only has to flip it and write the new value down. This is the entire runtime.

const KEY = 'theme';
const root = document.documentElement;
const button = document.querySelector('#theme-toggle');

function isDark() {
  return root.classList.contains('dark');
}

function render() {
  const dark = isDark();
  button.setAttribute('aria-pressed', String(dark));
  button.textContent = dark ? 'Light theme' : 'Dark theme';
}

button.addEventListener('click', () => {
  const next = !isDark();
  root.classList.toggle('dark', next);
  try {
    localStorage.setItem(KEY, next ? 'dark' : 'light');
  } catch {
    /* nothing to persist to — the toggle still works for this page */
  }
  render();
});

render();

The label and aria-pressed are recomputed from the DOM rather than from a variable held in the module. That sounds pedantic, but it means the button can never disagree with the page: whatever the class says is what the button reports.

Keep the click handler synchronous. A theme switch that animates its own state change is the one place where a transition genuinely hurts — the eye reads the delay as lag, not polish.

Also resist the urge to store the whole theme object. A single string is enough, it is trivially forwards-compatible, and it is small enough that a future version of the site can read it without a migration.

Improving the experience

Two refinements make this feel finished. The first is following the system preference for visitors who never touched the button. If someone flips their operating system to dark at sunset, the site should follow — but only until they express a preference of their own.

const media = window.matchMedia('(prefers-color-scheme: dark)');

media.addEventListener('change', (event) => {
  let stored = null;
  try {
    stored = localStorage.getItem(KEY);
  } catch {
    /* ignore */
  }
  if (stored === 'light' || stored === 'dark') return;
  root.classList.toggle('dark', event.matches);
  render();
});

The guard is the important line. Without it, the site would overrule a deliberate choice the moment the operating system changed, which is exactly the behaviour that makes people stop trusting the control.

The second refinement is a soft colour transition, applied to surfaces only and disabled for anybody who has asked for less motion. Transitioning every property on every element is what makes theme switches feel sluggish; two properties on two selectors is enough.

@media (prefers-reduced-motion: no-preference) {
  body,
  .bar,
  .card {
    transition:
      background-color 180ms ease,
      border-color 180ms ease;
  }
}

Finally, keep the toggle usable without a pointer. It is already a real <button>, so it is focusable and operable with the keyboard by default — the only thing left is to make sure the focus ring survives your reset.

#theme-toggle {
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 0.4rem 0.9rem;
  background: var(--surface);
  color: var(--text);
  cursor: pointer;
}

#theme-toggle:focus-visible {
  outline: 2px solid currentColor;
  outline-offset: 2px;
}

If you take one thing from this section, make it that: a theme toggle is a control, and controls have to survive a keyboard, a screen reader and a slow connection.

Conclusion

We covered the whole path — a variable-driven colour system, a class on the root element, a blocking script that removes the flash, a button that keeps its own label honest, a system-preference listener that steps aside when the visitor decides, and a transition that respects motion preferences.

None of it needs a framework or a dependency, and the total cost is well under a kilobyte. Adapt the variable names to your own scale, drop the script into a layout that renders on every page, and you have a theme switch that behaves correctly the first time somebody reloads.

2026 — Built by Mila Vance