Why Dark Mode Matters for Modern Web Design

Dark mode has evolved from a niche preference into a standard accessibility feature. By offering a light-on-dark color inversion, you reduce eye strain in low-light environments, conserve battery life on OLED screens, and give users greater control over their browsing experience. Studies show that prolonged exposure to bright interfaces can cause discomfort, so providing a toggle is a simple, user-centric improvement. Whether you’re building a blog, portfolio, or SaaS dashboard, implementing a JavaScript-driven dark mode toggle signals that your site respects user choice and modern design trends.

Beyond aesthetics, dark mode can improve readability for users with visual sensitivities or conditions like photophobia. It also aligns with system-wide dark mode settings in operating systems and browsers, creating a cohesive experience. In this guide, you’ll learn a robust, production-ready approach using vanilla JavaScript, CSS custom properties, and localStorage persistence — no frameworks required.

Core Techniques: CSS Classes vs. Custom Properties

There are two primary ways to define dark mode styles: toggling a class on a wrapper element, or swapping CSS custom property (variable) values. Both work well, but custom properties offer better scalability and maintenance for large projects.

Class-Based Toggle

The simplest method adds or removes a class like .dark-mode on the <body> or a root container. Your CSS then overrides rules under that selector:

body.dark-mode {
  background-color: #121212;
  color: #f0f0f0;
}
body.dark-mode a {
  color: #80cbc4;
}

This approach is straightforward but forces you to duplicate selectors for every element that changes. For small sites it’s fine, but for larger projects the repetition becomes error-prone.

Custom Properties (CSS Variables)

A more elegant solution uses :root variables and toggles a class that swaps their values:

:root {
  --bg: #ffffff;
  --text: #222222;
  --link: #1a73e8;
}
body.dark-mode {
  --bg: #121212;
  --text: #e0e0e0;
  --link: #4fc3f7;
}
body {
  background-color: var(--bg);
  color: var(--text);
}
a { color: var(--link); }

Now you change every themed element by editing a single block of variables. This pattern is recommended for its maintainability and clarity. The JavaScript remains identical — you toggle .dark-mode on the <body>.

Building the Toggle: HTML and Basic JavaScript

Start with a minimal HTML structure. Create a button that users can click to switch themes:

<button id="themeToggle" aria-label="Toggle dark mode">🌙</button>

Include an aria-label for accessibility. The emoji (or an icon) should reflect the current state — we’ll update it with JavaScript. Next, add the CSS variables as shown above. Now let’s wire up the toggle:

const toggleBtn = document.getElementById('themeToggle');
const body = document.body;

toggleBtn.addEventListener('click', () => {
  body.classList.toggle('dark-mode');
  // Update button icon
  const isDark = body.classList.contains('dark-mode');
  toggleBtn.textContent = isDark ? '☀️' : '🌙';
  toggleBtn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
});

This basic version works immediately. Click the button, the background and text colors change, and the button icon flips. However, it forgets the user’s choice on page reload. That’s where persistence comes in.

Persisting User Preference with localStorage

To remember the user’s choice across sessions, store a simple key-value pair in localStorage. On page load, read the value and apply the correct theme before any paint occurs. This prevents a flash of unstyled content (FOUC).

Storing and Retrieving the Theme

Create a small utility:

function getThemePreference() {
  return localStorage.getItem('theme'); // 'dark' | 'light' | null
}
function setThemePreference(theme) {
  localStorage.setItem('theme', theme);
}

Now wrap the toggle logic to use these functions:

const savedTheme = getThemePreference();
if (savedTheme === 'dark') {
  body.classList.add('dark-mode');
  toggleBtn.textContent = '☀️';
  toggleBtn.setAttribute('aria-label', 'Switch to light mode');
}

toggleBtn.addEventListener('click', () => {
  body.classList.toggle('dark-mode');
  const isDark = body.classList.contains('dark-mode');
  toggleBtn.textContent = isDark ? '☀️' : '🌙';
  toggleBtn.setAttribute('aria-label', isDark ? 'Switch to light mode' : 'Switch to dark mode');
  setThemePreference(isDark ? 'dark' : 'light');
});

Preventing Flash of Unstyled Content (FOUC)

Because localStorage is synchronous, apply the dark class before the browser renders. Place the following script in the <head> of your document, before any CSS or page content:

<script>
  (function() {
    const theme = localStorage.getItem('theme');
    if (theme === 'dark') {
      document.documentElement.classList.add('dark-mode');
    }
  })();
</script>

This ensures that if the user had dark mode enabled, the .dark-mode class is already present when the CSS loads — no flicker.

Honoring System Preference with prefers-color-scheme

Many users set their operating system to dark mode automatically. You can respect that choice using the prefers-color-scheme media query. Combine it with localStorage so that manual toggles override the system preference.

Detection Code

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

Logic flow on page load:

  • If localStorage has a saved theme, use that (explicit user choice).
  • Otherwise, fall back to the system preference.
  • If neither, default to light mode.
const saved = getThemePreference();
if (saved) {
  if (saved === 'dark') applyDark();
} else if (systemPrefersDark) {
  applyDark();
  setThemePreference('dark'); // optional: sync to localStorage
}

This ensures users get a seamless experience: if they haven’t manually toggled, the site follows their OS setting. You can also listen for change events on the media query:

window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!getThemePreference()) {
    // No manual override, follow system
    if (e.matches) {
      applyDark();
    } else {
      removeDark();
    }
  }
});

This dynamic update is especially nice for users who change system themes during a browsing session.

Accessibility and Usability Considerations

Dark mode isn't just about flipping colors — it must remain usable and accessible.

Contrast Ratios

Maintain a minimum contrast ratio of 4.5:1 for normal text and 3:1 for large text (WCAG AA). Avoid pure black (#000) on pure white (#FFF) because it causes halation for some readers. Instead, use dark grays like #121212 for background and #e0e0e0 for text. Test with tools like the WebAIM Contrast Checker.

Transitions and Animations

Abrupt color changes can be jarring. Use smooth transitions:

body {
  transition: background-color 0.3s ease, color 0.3s ease;
}

If you apply transitions to every element, performance can suffer. Instead, transition only the background-color and color on the body, and let children inherit. Avoid transitioning all properties.

Respecting Reduced Motion

Some users prefer no animation. Respect their system setting with:

@media (prefers-reduced-motion: reduce) {
  body {
    transition: none;
  }
}

Focus Indicators

Ensure your toggle button has a visible focus ring in both themes. Use outline or box-shadow that contrasts well:

#themeToggle:focus-visible {
  outline: 2px solid var(--link);
  outline-offset: 2px;
}

Enhancing the Toggle UI: Icons, SVG, and Text

Rather than emoji (which render differently across platforms), use inline SVGs or Unicode symbols. For a professional look, embed a moon and sun SVG within the button and toggle visibility via class or inline style. Example:

<button id="themeToggle" aria-label="Toggle theme">
  <span class="icon-sun" aria-hidden="true">☀️</span>
  <span class="icon-moon" aria-hidden="true">🌙</span>
</button>

CSS:

.icon-moon { display: none; }
.dark-mode .icon-sun { display: none; }
.dark-mode .icon-moon { display: inline; }

This approach keeps the HTML clean and uses CSS for visual state, which is more performant than updating textContent on every click. Use aria-hidden="true" on decorative icons.

Handling Multiple Toggle Buttons

If your site has a header, footer, or mobile menu, you might want multiple toggle buttons that stay in sync. Use a data attribute or class to identify all toggle elements, then attach the same click handler to each:

const togglers = document.querySelectorAll('[data-theme-toggle]');
togglers.forEach(btn => {
  btn.addEventListener('click', toggleTheme);
});
function toggleTheme() {
  body.classList.toggle('dark-mode');
  const isDark = body.classList.contains('dark-mode');
  // Update all buttons
  togglers.forEach(btn => {
    btn.textContent = isDark ? '☀️' : '🌙';
    btn.setAttribute('aria-label', isDark ? 'Switch to light' : 'Switch to dark');
  });
  setThemePreference(isDark ? 'dark' : 'light');
}

On page load, iterate the buttons to set the correct state. This pattern keeps your UI consistent without duplication.

Performance and Browser Considerations

The JavaScript required for a dark mode toggle is minimal — well under 1 KB gzipped. Still, there are best practices to avoid performance pitfalls:

  • Use CSS transitions sparingly: Animate only background-color, color, and border-color. Avoid animating box-shadows or large areas.
  • Avoid reflows: Changing colors does not trigger layout reflows, only repaints. This is efficient.
  • Lazy load heavy images: If your dark mode includes different images (e.g., a light logo vs dark logo), use <picture> elements with media queries rather than swapping src via JS.
  • Browser support: localStorage, classList.toggle, and CSS custom properties work in all modern browsers. IE11 requires a polyfill for custom properties, but support is negligible today.

If you need to support legacy browsers, fall back to the class-based method without variables.

Testing Dark Mode Implementation

Before shipping, test extensively:

  • Toggle on and off while navigating between pages.
  • Refresh with dark mode enabled — verify no FOUC.
  • Change your OS theme while the site is open — the dynamic listener should update the interface.
  • Use browser DevTools to emulate prefers-color-scheme (Chrome: Rendering tab).
  • Test with keyboard navigation: Tab to the button and press Enter/Space.
  • Check contrast of all text, links, and interactive elements in both modes using an automation tool like axe DevTools.

Also consider testing on real mobile devices where battery savings from dark mode are more noticeable.

Summary and Best Practices Checklist

Here’s a quick reference for your implementation:

  • Use CSS custom properties for scalable theming.
  • Apply the .dark-mode class to <html> via a blocking <script> in <head> to prevent flash.
  • Store the user’s explicit choice in localStorage.
  • Respect prefers-color-scheme as a fallback.
  • Provide a clear, accessible toggle with ARIA labels and keyboard support.
  • Use smooth transitions with reduced-motion respect.
  • Test contrast and usability in both themes.

By following these guidelines, you’ll deliver a dark mode feature that feels native, respects user preferences, and enhances the overall quality of your website.

For deeper reading, refer to the MDN guide on CSS custom properties and the web.dev article on prefers-color-scheme.