Build a Robust Dark Mode Toggle — The Real Tutorial
Dark mode is one of those tiny features that instantly make your site feel modern. But a correct, accessible, and production-ready implementation needs more than toggling colors — it needs persistence, system-preference respect, smooth transitions, theming scaleability, and good UX. I’ll walk you through everything: basics → advanced → React/SSR tips → common mistakes → exercises.
- Basic toggle (HTML + CSS + tiny JS)
- Persistent toggle with
localStorage
- Modern approach using CSS variables for scalable theme switches
- Tailwind & React notes + SSR considerations
Estimated reading: ~10–15 minutes. Skill level: Beginner → Intermediate. All code shown in pre/code
blocks is ready to copy.
Why do dark mode toggles break? (Real talk)
Because most people implement only visuals and skip UX and persistence. The common issues I see:
- No persistence → user toggles but loses choice on reload
- No accessibility → button not keyboard-friendly or lacks ARIA
- Hard-coded colors → site grows and changing theme requires rewriting CSS
- No system preference fallback → ignoring
prefers-color-scheme
1) Basic toggle — the absolute minimum
We’ll start tiny. HTML + CSS + 2-line JS. This is great for learning.
HTML
<header> <h1>My Site</h1> <button id="toggle">Toggle Dark Mode</button> </header>
CSS
body { background:#fff; color:#111; transition:background 200ms ease, color 200ms ease; } body.dark { background:#0b1220; color:#e6eef8; }
JS
const toggle = document.getElementById('toggle'); toggle.addEventListener('click', () => document.body.classList.toggle('dark'));
This works. But it doesn't remember the user preference and it won't scale. Let's make it real.
2) Persist choice using localStorage
Save the user's choice so the site respects it on reload. Also read localStorage
before the page paints so you avoid a flash-of-wrong-theme (FOWT).
Complete example (HTML + CSS + JS)
<!— Put inside <body> —> <button id="toggle" aria-pressed="false">Toggle Dark Mode</button> <script> const STORAGE_KEY = 'theme-pref'; const toggle = document.getElementById('toggle'); // Apply saved theme before anything else (helps avoid flash) (function initTheme() { try { const saved = localStorage.getItem(STORAGE_KEY); if (saved === 'dark') document.body.classList.add('dark'); } catch (e) { // ignore storage errors (privacy mode) } })(); toggle.addEventListener('click', () => { const isNowDark = document.body.classList.toggle('dark'); toggle.setAttribute('aria-pressed', String(isNowDark)); try { localStorage.setItem(STORAGE_KEY, isNowDark ? 'dark' : 'light'); } catch (e) { /* ignore */ } }); </script>
Tip: call initTheme()
inline or in a tiny script before your main CSS loads to avoid a flash of the wrong theme.
3) Respect system preference (prefers-color-scheme
)
Users often prefer their OS theme. Use it unless the user explicitly saved a preference.
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const saved = localStorage.getItem('theme-pref'); if (!saved) { if (prefersDark) document.body.classList.add('dark'); }
Also listen to changes — if user changes OS theme while on site and they didn't pick a preference, follow the OS:
if (window.matchMedia) { const mq = window.matchMedia('(prefers-color-scheme: dark)'); mq.addEventListener('change', e => { if (!localStorage.getItem('theme-pref')) { document.body.classList.toggle('dark', e.matches); } }); }
4) Scalable theming with CSS Variables (recommended)
Toggling a class that flips CSS variables is the most scalable approach. You define colors once and use variables everywhere — easier to maintain than dozens of duplicated classes.
Define variables
:root { --bg: #ffffff; --surface: #ffffff; --text: #0f172a; --muted: #475569; --primary: #2563eb; } :root.dark { --bg: #0b1220; --surface: #07101a; --text: #e6eef8; --muted: #9fb1cc; --primary: #60a5fa; }
Use variables in UI
body { background: var(--bg); color: var(--text); } .card { background: var(--surface); color: var(--text); box-shadow: 0 6px 18px rgba(2,6,23,0.06); }
Why CSS variables? You can theme anything — spacing, radius, animations — and switching is just toggling a class. Works great with component libraries.
5) Animations & transitions — no flicker
Transitions feel nice, but animate only properties that are cheap to animate. Use transition: background 180ms ease, color 180ms ease;
— don't animate layout churning properties.
:root, :root.dark { transition: background 180ms ease, color 180ms ease; }
Avoid animating box-shadow
or anything that hurts paint performance on lower-end devices.
6) Accessible toggle button
Make the toggle keyboard-friendly and properly announced by screen readers.
<button id="theme-toggle" aria-pressed="false" aria-label="Toggle dark mode"> <span class="icon">☀️/🌙</span> </button>
JS should update aria-pressed
and the visible icon/label.
function applyTheme(theme) { const isDark = theme === 'dark'; document.documentElement.classList.toggle('dark', isDark); const btn = document.getElementById('theme-toggle'); btn.setAttribute('aria-pressed', String(isDark)); btn.querySelector('.icon').textContent = isDark ? '🌙' : '☀️'; }
7) Tailwind version (quick)
If you use Tailwind, you can still use CSS variables or the .dark
strategy. Tailwind supports a dark:
prefix that toggles classes when class="dark"
exists on <html>
.
<!-- Tailwind setup: put the `dark` class on <html> --> <html class="dark"> <body class="bg-white dark:bg-slate-900 text-slate-900 dark:text-slate-100"> <!-- content --> </body> </html>
Toggle document.documentElement.classList.toggle('dark')
from JS to switch Tailwind's dark styles.
8) React + SSR notes (important for production)
If you use server-side rendering (Next.js, Remix, etc.) you must avoid theme flash on first paint. Approaches:
- Hydrate with server-detected theme: On server, detect the user's cookie or preference and render correct theme class on
<html>
. - Inline script before app bundle: A tiny script in the server-rendered HTML that reads cookie/localStorage and sets
document.documentElement.classList
before React mounts.
<script> (function() { try { var t = localStorage.getItem('theme-pref'); if(t) document.documentElement.classList.add(t === 'dark' ? 'dark' : 'light'); else if(window.matchMedia('(prefers-color-scheme: dark)').matches) document.documentElement.classList.add('dark'); } catch(e){} })(); </script>
Put that script as early as possible in the <head> so it runs before CSS loads.
9) Common mistakes & how to avoid them
- Storing booleans incorrectly: store strings like
'dark'
or'light'
— don’t rely on truthy booleans that can be ambiguous. - Animating expensive properties: avoid animating
box-shadow
or forcing layout changes. - Not updating ARIA: toggle
aria-pressed
and icon text for screen readers. - Forgetting system preference: default to the user's OS preference if they didn’t pick anything.
10) Troubleshooting
If users report flicker or theme not persisting:
- Check if your init script runs before CSS — it should run as early as possible.
- Open devtools and check
localStorage
key name — ensure no typos. - Confirm you’re toggling the same element the CSS expects (e.g.,
document.documentElement
vsdocument.body
).
11) Small enhancements (boosts UX)
- Animate a small background gradient when toggling to make it feel smooth.
- Provide a system-default option in UI: Light / Dark / System.
- Add a subtle focus ring for keyboard users.
- Store preference on server (optional) for logged-in users so theme is consistent across devices.
- Implement the CSS variables approach and theme a header + card + button.
- Make a 3-option toggle: Light / Dark / System — store as
'light'/'dark'/'system'
in localStorage. - Integrate the inline init script into a Next.js _document_ (or SSR template) so there's no flash on first load.
12) Full ready-to-paste demo (HTML block)
Drop this entire block into a blog post or any HTML file. It includes CSS variables, accessible toggle, persistence, and prefers-color-scheme handling. Replace the #
blog link at the end if you want.
<div id="dark-mode-demo" style="max-width:900px;margin:0 auto;padding:20px;font-family:Inter,system-ui,sans-serif;"> <style> :root{--bg:#ffffff;--surface:#ffffff;--text:#0f172a;--muted:#475569;--primary:#2563eb;transition:background 180ms ease,color 180ms ease} :root.dark{--bg:#0b1220;--surface:#07101a;--text:#e6eef8;--muted:#9fb1cc;--primary:#60a5fa} #dark-mode-demo{background:var(--bg);color:var(--text);padding:18px;border-radius:12px;box-shadow:0 6px 18px rgba(2,6,23,0.04)} .row{display:flex;align-items:center;justify-content:space-between;gap:12px} .brand{display:flex;align-items:center;gap:12px} .logo{width:44px;height:44px;border-radius:10px;background:linear-gradient(135deg,var(--primary),#7dd3fc);display:flex;align-items:center;justify-content:center;color:white;font-weight:700} button#theme-toggle{background:transparent;border:1px solid rgba(15,23,42,0.06);padding:8px 10px;border-radius:10px;cursor:pointer;color:var(--text)} .card{margin-top:14px;background:var(--surface);padding:14px;border-radius:10px;box-shadow:0 6px 18px rgba(2,6,23,0.04)} .small{font-size:13px;color:var(--muted)} </style> <div class="row"> <div class="brand"> <div class="logo">CSS</div> <div> <h2 style="margin:0;font-size:18px">Dark Mode Demo</h2> <div class="small">Plain JS + CSS variables + localStorage</div> </div> </div> <button id="theme-toggle" aria-pressed="false" aria-label="Toggle dark mode"> <span id="theme-icon">☀️</span> <span id="theme-text">Light</span> </button> </div> <div class="card"> <h3 style="margin:0 0 8px 0">Example Card</h3> <p class="small">This card changes with the theme. Try toggling — preference is saved in localStorage.</p> </div> <script> (function(){ const KEY = 'theme-preference-v1'; const toggle = document.getElementById('theme-toggle'); const icon = document.getElementById('theme-icon'); const text = document.getElementById('theme-text'); const root = document.documentElement; const ICONS = { sun: '☀️', moon: '🌙' }; // Apply theme before anything (help avoid flash) (function init() { try { const saved = localStorage.getItem(KEY); if (saved === 'dark') root.classList.add('dark'); else if (!saved && window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { root.classList.add('dark'); } } catch(e){} updateUI(); })(); function updateUI() { const isDark = root.classList.contains('dark'); toggle.setAttribute('aria-pressed', String(isDark)); icon.textContent = isDark ? ICONS.moon : ICONS.sun; text.textContent = isDark ? 'Dark' : 'Light'; } toggle.addEventListener('click', () => { const next = root.classList.toggle('dark'); try { localStorage.setItem(KEY, next ? 'dark' : 'light'); } catch(e){} updateUI(); }); // follow system changes only if user hasn't set explicit preference if (window.matchMedia) { const mq = window.matchMedia('(prefers-color-scheme: dark)'); mq.addEventListener && mq.addEventListener('change', e => { try { const saved = localStorage.getItem(KEY); if (!saved) { // only follow system when no explicit choice root.classList.toggle('dark', e.matches); updateUI(); } } catch(e){} }); } })(); </script> </div>
13) Final checklist before you ship
- Does the page respect
prefers-color-scheme
if no user choice? - Is the toggle keyboard-accessible and does it update
aria-pressed
? - Do you avoid flashing wrong theme on first paint (use inline init script)?
- Are your theme values centralized (CSS variables) so a single change updates everywhere?
14) Where to go next (resources & ideas)
- Turn the toggle into a three-state control: Light / Dark / System.
- Add transition-aware elements (animate only safe properties).
- Connect theme preference to user profile on your backend for cross-device sync.
If you want, I can: provide a ready-to-embed CodePen of the demo, convert the demo to Tailwind classes, or produce a React + Next.js ready example that handles SSR properly. Tell me which one and I’ll output it ready to paste.
Published by sarthak .k · Want more informative things and tweets like this : followr here
Social Plugin