All articles
CSS Fundamentals

How to Implement Dark Mode in CSS the Right Way

20 January 2025 9 min read CSS Fundamentals

Build a robust dark mode using CSS custom properties, prefers-color-scheme, localStorage persistence and smooth transitions. No JavaScript frameworks required.

Dark mode is no longer a nice-to-have — users expect it, and their OS respects it. But there's a big difference between a dark mode that works and one that feels polished and intentional.

The automatic approach — prefers-color-scheme

The browser exposes the user's OS preference via a media query:

:root {
  --bg: #ffffff;
  --text: #1a1a1a;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0d0d0d;
    --text: #f0ede8;
  }
}

body {
  background: var(--bg);
  color: var(--text);
}

This is the minimum viable dark mode — it respects the OS preference automatically, with zero JavaScript.

Adding a manual toggle

For user control, use a data-theme attribute on html and persist the choice in localStorage:

// Toggle function
function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
  // Update icon
  document.getElementById('themeIcon').className =
    theme === 'dark' ? 'bi bi-sun-fill' : 'bi bi-moon-stars-fill';
}

// On load — respect saved preference, fall back to OS
const saved = localStorage.getItem('theme');
const preferred = window.matchMedia('(prefers-color-scheme: dark)').matches
  ? 'dark' : 'light';
setTheme(saved || preferred);

Then in CSS, use [data-theme="dark"] instead of the media query — this lets JavaScript override the OS preference.

Common dark mode mistakes
  • Pure black backgrounds#000000 is too stark. Use a very dark grey like #0d0d0d or #111111. It's easier on the eyes and makes layered surfaces possible.
  • Inverting imagesfilter: invert(1) on photos looks terrible. Use filter: invert(1) hue-rotate(180deg) for diagrams only.
  • Dark shadows on dark backgrounds — box shadows become invisible. Replace with a lighter border or a very subtle glow.
  • Forgetting form elements — inputs, selects and checkboxes need explicit dark styles. Don't assume the browser will handle them.
Smooth transition between themes
/* Add to your global CSS — after initial load to prevent flash */
.theme-ready * {
  transition:
    background-color 0.2s ease,
    color 0.2s ease,
    border-color 0.2s ease;
}
// Add class after first paint to prevent transition flash on load
window.addEventListener('DOMContentLoaded', () => {
  requestAnimationFrame(() => {
    document.documentElement.classList.add('theme-ready');
  });
});

Without the theme-ready guard, every page load will flash through the transition even before the user does anything.