HTML Setup

<button id="theme-toggle" aria-label="Toggle dark mode">
  <span class="icon-sun">☀️</span>
  <span class="icon-moon">🌙</span>
</button>
/* Show correct icon based on current theme */
[data-theme="light"] .icon-moon { display: none; }
[data-theme="dark"] .icon-sun { display: none; }

CSS Variables

[data-theme="light"] {
  --bg: #ffffff;
  --text: #0f172a;
  --surface: #f1f5f9;
  --border: #e2e8f0;
}

[data-theme="dark"] {
  --bg: #060b14;
  --text: #dde8f8;
  --surface: #0b1220;
  --border: #0f1e32;
}

body {
  background: var(--bg);
  color: var(--text);
  transition: background 0.3s ease, color 0.3s ease;
}

JavaScript Logic

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

toggle.addEventListener('click', () => {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  applyTheme(next);
  localStorage.setItem('theme', next);
});

function applyTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
}

Initialisation — No Flash on Load

This must go in <head> before CSS loads to prevent a flash of the wrong theme:

<script>
(function() {
  const saved = localStorage.getItem('theme');
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const theme = saved || (prefersDark ? 'dark' : 'light');
  document.documentElement.setAttribute('data-theme', theme);
})();
</script>
✅ Result: On first visit, the user's OS preference is used. After they toggle, their choice is saved and applied immediately on every subsequent visit — no flash, no delay.