PickCSS
Back to Blog

Implementing Dark Mode with CSS Variables

January 10, 20268 min readBy Brandon Watson

Dark mode has gone from a nice-to-have to an expected feature. CSS variables make implementing dark mode straightforward, maintainable, and performant. Here's how to do it right.

CSS-Only Dark Mode with prefers-color-scheme

The simplest approach uses the prefers-color-scheme media query to automatically match the user's system preference:

:root {
  --color-background: #ffffff;
  --color-foreground: #0f172a;
  --color-primary: #3b82f6;
  --color-muted: #f1f5f9;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-background: #0f172a;
    --color-foreground: #f8fafc;
    --color-primary: #60a5fa;
    --color-muted: #1e293b;
  }
}

This approach is zero-JavaScript and respects user preferences automatically. The downside is users can't override the system setting on your site.

Toggle-Based Dark Mode (Class Strategy)

For user-controllable dark mode, use a class on the root element:

:root {
  --color-background: #ffffff;
  --color-foreground: #0f172a;
}

:root.dark {
  --color-background: #0f172a;
  --color-foreground: #f8fafc;
}

Toggle the class with JavaScript:

// Toggle dark mode
document.documentElement.classList.toggle('dark');

// Set specific mode
document.documentElement.classList.add('dark');
document.documentElement.classList.remove('dark');

// Persist preference
localStorage.setItem('theme', 'dark');

Combining Both Approaches

The best UX combines system preference with user override:

// On page load
function initTheme() {
  const stored = localStorage.getItem('theme');

  if (stored) {
    // User has explicit preference
    document.documentElement.classList.toggle('dark', stored === 'dark');
  } else {
    // Fall back to system preference
    const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
    document.documentElement.classList.toggle('dark', prefersDark);
  }
}

// Listen for system preference changes
window.matchMedia('(prefers-color-scheme: dark)')
  .addEventListener('change', (e) => {
    if (!localStorage.getItem('theme')) {
      document.documentElement.classList.toggle('dark', e.matches);
    }
  });

The CSS Variable Approach

CSS variables are ideal for dark mode because:

  • Single source of truth: Change colors in one place
  • No specificity wars: Variables cascade naturally
  • Works everywhere: Any CSS that uses the variables automatically adapts
  • Transition support: Smooth theme transitions with CSS
:root {
  --transition-theme: background-color 0.2s, color 0.2s;
}

body {
  background-color: var(--color-background);
  color: var(--color-foreground);
  transition: var(--transition-theme);
}

Tailwind CSS Dark Mode Setup

Tailwind supports dark mode out of the box. Configure it in tailwind.config.js:

/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: 'class', // or 'media' for system preference only
  theme: {
    extend: {
      colors: {
        background: 'var(--color-background)',
        foreground: 'var(--color-foreground)',
        primary: 'var(--color-primary)',
      },
    },
  },
}

Then use dark: variants in your HTML:

<div class="bg-white dark:bg-slate-900">
  <p class="text-slate-900 dark:text-white">
    This text adapts to dark mode
  </p>
</div>

Or better yet, use CSS variables so you don't need dark: variants:

<div class="bg-[var(--color-background)]">
  <p class="text-[var(--color-foreground)]">
    This automatically adapts—no dark: needed
  </p>
</div>

Avoiding Flash of Unstyled Content (FOUC)

The biggest dark mode pitfall is a flash of light mode before JavaScript runs. Fix this with a blocking script in your <head>:

<head>
  <script>
    // This runs before any content renders
    (function() {
      const theme = localStorage.getItem('theme');
      const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;

      if (theme === 'dark' || (!theme && prefersDark)) {
        document.documentElement.classList.add('dark');
      }
    })();
  </script>
</head>

In Next.js, use a similar approach with dangerouslySetInnerHTML or the next-themes library.

Common Pitfalls

1. Forgetting Image Contrast

Images designed for light backgrounds may look wrong on dark. Consider:

  • Using transparent PNGs with appropriate colors
  • Providing dark mode variants of logos
  • Adding subtle backgrounds behind images

2. Pure Black Backgrounds

Pure black (#000000) can be harsh and cause "halation" on OLED screens. Use dark grays like #0f172a or #1a1a1a instead.

3. Insufficient Contrast

Test your dark mode colors for WCAG contrast ratios. Text should have at least 4.5:1 contrast with the background.

4. Forgetting Third-Party Components

Libraries like maps, embeds, or UI components may not respect your dark mode. Plan for overrides or dark-compatible alternatives.

How PickCSS Generates Light + Dark Themes

PickCSS exports both light and dark mode tokens automatically. Your theme.css file includes:

:root {
  /* Light mode (default) */
  --color-background: #ffffff;
  --color-foreground: #0f172a;
  --color-primary: #3b82f6;
  /* ... 70+ variables */
}

:root.dark {
  /* Dark mode overrides */
  --color-background: #0f172a;
  --color-foreground: #f8fafc;
  --color-primary: #60a5fa;
  /* ... matching dark variants */
}

Dark mode colors are automatically calculated to maintain proper contrast and visual harmony.

Getting Started

Want properly balanced light and dark themes? PickCSS generates both with a single design session—no manual color picking required.

Create your theme and get dark mode for free.

Put this into practice

Create a complete design system with production-ready tokens in minutes.

Start Building