DEV Community

Snappy Tools
Snappy Tools

Posted on

How to Implement Dark Mode: A Complete CSS and JavaScript Guide

Dark mode is now a standard expectation for web applications. Here's a practical implementation guide covering the CSS-only approach, the JavaScript toggle approach, and how to handle user preferences correctly.

The CSS-only approach (system preference only)

If you only need to respond to the user's OS-level dark mode setting, prefers-color-scheme does the job with no JavaScript:

:root {
  --bg: white;
  --text: #1a1a1a;
  --card-bg: #f8f8f8;
  --border: #e2e8f0;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a2e;
    --text: #e8e8e8;
    --card-bg: #16213e;
    --border: #2d3748;
  }
}

body {
  background: var(--bg);
  color: var(--text);
}
Enter fullscreen mode Exit fullscreen mode

Pros: no JavaScript, instant, no flash of incorrect theme on load.

Cons: user can't override — if their OS is in light mode, they can't switch to dark on your site.

The full implementation: system default + user override

This approach:

  1. Reads the OS preference as the default
  2. Lets users toggle with a button
  3. Persists the user's choice in localStorage
  4. Prevents flash on page load

The CSS

/* Light theme (default) */
:root {
  --bg: #ffffff;
  --text: #1a202c;
  --surface: #f7fafc;
  --border: #e2e8f0;
  --accent: #2f855a;
}

/* Dark theme — applied by the data-theme attribute */
[data-theme="dark"] {
  --bg: #1a202c;
  --text: #f7fafc;
  --surface: #2d3748;
  --border: #4a5568;
  --accent: #68d391;
}

body {
  background: var(--bg);
  color: var(--text);
  transition: background 0.2s, color 0.2s;
}
Enter fullscreen mode Exit fullscreen mode

The JavaScript

// Apply theme before page renders to prevent flash
(function() {
  const stored = localStorage.getItem('theme');
  const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
  const theme = stored || (prefersDark ? 'dark' : 'light');
  document.documentElement.setAttribute('data-theme', theme);
})();

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

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

// Set initial button state
const current = document.documentElement.getAttribute('data-theme');
toggle.textContent = current === 'dark' ? '☀️' : '🌙';
Enter fullscreen mode Exit fullscreen mode

Why the IIFE? The immediately invoked function at the top runs synchronously before any render, so the correct theme is applied before the first paint. Without it, users on dark mode see a white flash before the script runs.

The HTML

<head>
  <!-- Inline the theme IIFE here, not in an external file -->
  <script>
    (function() {
      const stored = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      document.documentElement.setAttribute(
        'data-theme',
        stored || (prefersDark ? 'dark' : 'light')
      );
    })();
  </script>
</head>
<body>
  <button id="theme-toggle" aria-label="Toggle dark mode">🌙</button>
</body>
Enter fullscreen mode Exit fullscreen mode

The script must be inline in <head> — loading it as an external file introduces a delay that causes the flash.

Handling system preference changes at runtime

If the user changes their OS setting while on your site, you can optionally respond:

window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    // Only follow system if user hasn't manually overridden
    if (!localStorage.getItem('theme')) {
      document.documentElement.setAttribute(
        'data-theme',
        e.matches ? 'dark' : 'light'
      );
    }
  });
Enter fullscreen mode Exit fullscreen mode

Colour selection for dark mode

Dark mode is not just inverting colours. Common mistakes:

Don't use pure black#000000 background with #ffffff text is harsh and tiring. Use a dark blue-grey like #1a202c or #121212 (Material Design's recommendation).

Elevation through lightness — in dark mode, raised surfaces (cards, modals) are slightly lighter than the base background, not darker. This mimics how physical surfaces look under ambient light.

Check contrast — dark mode colours need WCAG contrast verification just like light mode. An orange accent that reads well on white may not have sufficient contrast on dark backgrounds. Use the Color Contrast Checker to verify both themes.

Test your colours in both themes — use the Color Picker to identify hex values from your design mockups, then check contrast ratios for both light and dark variants.

Accessibility notes

  • Provide an explicit toggle — don't force users to change their OS setting to switch modes
  • Use aria-label on the toggle button
  • Ensure focus indicators are visible in both themes
  • Don't use colour alone to convey information — icons, text labels, or patterns should complement colour cues

Common mistakes

Using class instead of data-theme — either works, but data-theme is more semantic and less likely to conflict with CSS libraries.

Forgetting images and SVGs — dark mode might require different asset versions. Use picture with source media="(prefers-color-scheme: dark)" or CSS filter: invert(1) for simple icon sets.

Animating the initial load — the transition on body looks smooth during toggle but causes a flash on initial load if the IIFE runs after a paint. Consider disabling transition on load with a class and re-enabling it after the page is interactive.


Dark mode done right is invisible to users who have it enabled and optional for everyone else. The core pattern — CSS variables for tokens, data-theme attribute to swap, IIFE to prevent flash, localStorage to persist — works reliably across all modern browsers and is straightforward to maintain.

Top comments (0)