Every dark mode tutorial starts with: "First, add an event listener to your toggle button, store the preference in localStorage, then apply a CSS class to the body…"

Boring. What if I told you CSS can handle the entire thing — theme selection, smooth transitions, and even persisting the choice — without a single line of JavaScript?

OK, I lied slightly about the "persisting" part. You need about 4 lines of JS for localStorage. But the core theme switching? Pure CSS, baby.

The Trick: Hidden Radio Inputs

HTML radio inputs are naturally "one selection at a time" elements. We can use this behavior to control which theme is active. The magic is CSS's :checked pseudo-class combined with the sibling selector ~.

Here's the structure:

<!-- Place these BEFORE your page wrapper -->
<input type="radio" name="theme" id="theme-light" checked class="theme-input">
<input type="radio" name="theme" id="theme-dark" class="theme-input">
<input type="radio" name="theme" id="theme-sepia" class="theme-input">

<div class="page-wrapper">
  <!-- Your entire site goes here -->
</div>

The radio inputs are hidden with CSS. The page wrapper is a sibling of the inputs. This relationship is everything.

CSS Variables for Themes

Define your light theme as the default on :root, then override with sibling selectors:

/* Default: Light theme */
:root {
  --bg: #f8f9fc;
  --text: #1a1f2c;
  --accent: #d97706;
}

/* Dark theme: activated when #theme-dark is checked */
#theme-dark:checked ~ .page-wrapper {
  --bg: #07070a;
  --text: #f3f4f6;
  --accent: #f59e0b;
}

/* Sepia theme */
#theme-sepia:checked ~ .page-wrapper {
  --bg: #f6efe2;
  --text: #433422;
  --accent: #b45309;
}

When a radio input becomes :checked, the CSS variables on .page-wrapper update instantly. All elements using those variables re-render. No class toggling. No DOM manipulation.

The Toggle Buttons

Use <label> elements linked to the radio inputs. Clicking a label checks its associated radio:

<div class="theme-selector">
  <span>Theme:</span>
  <label for="theme-light">Light</label>
  <label for="theme-dark">Dark</label>
  <label for="theme-sepia">Sepia</label>
</div>

No onclick handlers. No addEventListener. The for attribute does all the work. HTML has had this feature since 1995 and most developers don't know about it.

Highlighting the Active Button

Use the same sibling selector pattern to style the active button:

#theme-light:checked ~ .page-wrapper .theme-btn-light,
#theme-dark:checked ~ .page-wrapper .theme-btn-dark,
#theme-sepia:checked ~ .page-wrapper .theme-btn-sepia {
  background: var(--accent);
  color: #fff;
}

The active theme button glows. The others stay neutral. Pure CSS state management.

Smooth Transitions

Add transitions to the page wrapper for buttery theme switches:

.page-wrapper {
  background-color: var(--bg);
  color: var(--text);
  transition: background-color 0.25s ease, color 0.25s ease;
}

The 4 Lines of JS (For Persistence Only)

CSS can't read localStorage. So we add this tiny script at the bottom of the page:

const theme = localStorage.getItem('theme');
if (theme) {
  const el = document.getElementById(`theme-${theme}`);
  if (el) el.checked = true;
}
document.querySelectorAll('.theme-input').forEach(input => {
  input.addEventListener('change', (e) => {
    localStorage.setItem('theme', e.target.id.replace('theme-', ''));
  });
});

On page load: restore the saved theme. On change: save the new choice. That's it. The actual theme switching is still handled by CSS.

Why This Approach is Better

  • Works without JS: If JavaScript fails to load, the theme system still works. Users just lose persistence.
  • No flash of wrong theme (FOWT): The radio input state is checked before CSS renders, so the correct theme appears on first paint.
  • Infinitely extensible: Want 5 themes? Add 5 radio inputs. The pattern scales with zero complexity.
  • Tiny footprint: 4 lines of JS vs the 50+ lines most tutorials use.

The next time someone tells you dark mode needs JavaScript, show them this. CSS has been capable of this for years. We just weren't creative enough to notice.