← Back to DevBytes

Mastering CSS Dark Mode: Tips and Best Practices

Understanding CSS Dark Mode

CSS dark mode is a design pattern that allows websites and applications to adapt their color schemes based on user preference or manual toggle. At its core, it leverages the prefers-color-scheme media query—a browser-level API that detects whether the user has requested a light or dark theme at the operating system level. Combined with CSS custom properties (variables), you can build a flexible, maintainable system that swaps entire color palettes with minimal code duplication.

Dark mode is not simply about inverting black and white. It requires careful consideration of contrast ratios, readability, depth perception, and brand identity. A well-implemented dark mode reduces eye strain in low-light environments, saves battery life on OLED screens, and respects user accessibility needs. Mastering it means understanding both the technical CSS mechanisms and the design principles that make dark interfaces feel polished and professional.

Why Dark Mode Matters

🚀 Deploy your AI agent in 10 minutes

Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.

Try it free →

Before diving into code, it is worth understanding the driving forces behind dark mode adoption:

How to Implement Dark Mode with CSS

There are several approaches to implementing dark mode, ranging from simple automatic detection to full manual toggle systems with persisted preferences. The most robust solutions combine multiple techniques.

Approach 1: The prefers-color-scheme Media Query

The simplest entry point is the prefers-color-scheme media query. It listens to the user's OS-level theme setting and applies styles accordingly. No JavaScript is required, and the response is instantaneous on page load.

/* Default light theme styles */
body {
  background-color: #ffffff;
  color: #1a1a1a;
}

a {
  color: #0066cc;
}

/* Dark theme overrides */
@media (prefers-color-scheme: dark) {
  body {
    background-color: #1a1a1a;
    color: #e6e6e6;
  }

  a {
    color: #66b3ff;
  }
}

This approach works well for simple sites, but it becomes cumbersome as your stylesheet grows. Repeating selectors inside the media query leads to bloated code and maintenance headaches. The solution is to pair this media query with CSS custom properties.

Approach 2: CSS Custom Properties for Theming

CSS custom properties (variables) allow you to define a palette once and reference it throughout your stylesheet. Dark mode becomes a matter of redefining those variables inside a media query—or within a class-based toggle system.

/* Define light theme variables on :root */
:root {
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #666666;
  --color-accent: #0066cc;
  --color-border: #e0e0e0;
  --color-shadow: rgba(0, 0, 0, 0.1);
  --transition-theme: background-color 0.3s ease, color 0.3s ease;
}

/* Apply variables throughout your styles */
body {
  background-color: var(--color-bg-primary);
  color: var(--color-text-primary);
  transition: var(--transition-theme);
}

.card {
  background-color: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  box-shadow: 0 2px 8px var(--color-shadow);
}

h1, h2, h3 {
  color: var(--color-text-primary);
}

p, li {
  color: var(--color-text-secondary);
}

a {
  color: var(--color-accent);
}

/* Dark theme variable overrides */
@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-primary: #121212;
    --color-bg-secondary: #1e1e1e;
    --color-text-primary: #e6e6e6;
    --color-text-secondary: #a0a0a0;
    --color-accent: #66b3ff;
    --color-border: #333333;
    --color-shadow: rgba(0, 0, 0, 0.4);
  }
}

With this structure, adding dark mode support to new components requires zero additional selectors—you simply use the existing variables. The transition property on the body smooths the visual switch, preventing a jarring flash.

Approach 3: Class-Based Manual Toggle with JavaScript

While automatic detection is excellent, many users want explicit control. A manual toggle lets users switch themes regardless of their OS setting. This requires adding a class (such as .dark-mode) to a high-level element, typically <body> or <html>, and then scoping your dark variables under that class selector.

/* Variables remain on :root for light theme */
:root {
  --color-bg-primary: #ffffff;
  --color-bg-secondary: #f5f5f5;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #666666;
  --color-accent: #0066cc;
  --color-border: #e0e0e0;
}

/* Dark theme variables scoped to a class */
body.dark-mode {
  --color-bg-primary: #121212;
  --color-bg-secondary: #1e1e1e;
  --color-text-primary: #e6e6e6;
  --color-text-secondary: #a0a0a0;
  --color-accent: #66b3ff;
  --color-border: #333333;
}

/* All component styles use the variables as before */
body {
  background-color: var(--color-bg-primary);
  color: var(--color-text-primary);
}

Now you need JavaScript to toggle the class and, ideally, persist the choice in localStorage.

// Select the toggle button and body element
const themeToggle = document.getElementById('theme-toggle');
const body = document.body;

// Check localStorage on page load
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
  body.classList.add('dark-mode');
} else if (savedTheme === 'light') {
  body.classList.remove('dark-mode');
}
// If no saved preference, fall back to system preference
// (You can also check prefers-color-scheme via JS)

// Toggle on button click
themeToggle.addEventListener('click', () => {
  const isDark = body.classList.toggle('dark-mode');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
});

Approach 4: Hybrid System (Best of Both Worlds)

The most complete solution combines automatic detection with manual override. On first visit, the site respects the OS preference. If the user manually toggles, their choice is saved and takes precedence. Here is a robust implementation:

// Hybrid theme manager
(function() {
  const body = document.body;
  const toggle = document.getElementById('theme-toggle');
  
  // Determine initial theme
  function getInitialTheme() {
    const saved = localStorage.getItem('theme');
    if (saved === 'dark' || saved === 'light') {
      return saved;
    }
    // Fall back to system preference
    if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
      return 'dark';
    }
    return 'light';
  }
  
  // Apply theme
  function applyTheme(theme) {
    if (theme === 'dark') {
      body.classList.add('dark-mode');
    } else {
      body.classList.remove('dark-mode');
    }
    localStorage.setItem('theme', theme);
  }
  
  // Initialize
  const initialTheme = getInitialTheme();
  applyTheme(initialTheme);
  
  // Toggle handler
  toggle.addEventListener('click', () => {
    const currentTheme = body.classList.contains('dark-mode') ? 'dark' : 'light';
    const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
    applyTheme(newTheme);
  });
  
  // Listen for OS theme changes in real time
  const systemDarkQuery = window.matchMedia('(prefers-color-scheme: dark)');
  systemDarkQuery.addEventListener('change', (e) => {
    // Only auto-switch if user hasn't set a manual preference
    // (you can track this with an additional flag in localStorage)
    const hasManualPreference = localStorage.getItem('theme-manual') === 'true';
    if (!hasManualPreference) {
      applyTheme(e.matches ? 'dark' : 'light');
    }
  });
})();

To track manual vs. automatic preference, set a theme-manual flag in localStorage when the user clicks the toggle. This prevents the system listener from overriding an explicit user choice.

Best Practices for Dark Mode Design

1. Never Use Pure Black (#000000)

Pure black backgrounds can feel harsh and cause ghosting artifacts on some displays. Instead, use dark gray tones like #121212 or #1a1a1a. These softer shades reduce eye strain while still providing excellent contrast. Material Design recommends #121212 as the surface baseline, with elevated surfaces getting progressively lighter shades to create depth.

/* Good dark mode surface hierarchy */
:root {
  --surface-ground: #121212;      /* Base background */
  --surface-elevated: #1e1e1e;    /* Cards, containers */
  --surface-overlay: #282828;     /* Modals, drawers */
  --surface-hover: #333333;       /* Interactive hover states */
}

2. Reduce Contrast Slightly for Body Text

In light mode, you typically aim for high contrast (e.g., #1a1a1a on white). In dark mode, pure white text (#ffffff) on a dark background can create halation effects—a glowing blur that makes text harder to read for some users. Slightly off-white colors like #e6e6e6 or #d4d4d4 improve readability. Aim for a contrast ratio of at least 15:1 for body text, but avoid the maximum 21:1 of pure white on pure black.

/* Readable dark mode text */
body {
  --text-primary: #e6e6e6;   /* Main body text */
  --text-secondary: #a0a0a0; /* Captions, metadata */
  --text-disabled: #666666;  /* Disabled states */
}

3. Desaturate Accent Colors

Highly saturated colors that look vibrant on white backgrounds can appear fluorescent and overwhelming on dark backgrounds. Reduce saturation by 10-30% for your accent colors in dark mode. A bright blue #0066cc in light mode might become #66b3ff or #5299e0 in dark mode—still recognizable but less intense.

/* Adjust accent saturation for dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --color-accent-blue: #5299e0;    /* Desaturated blue */
    --color-accent-green: #4caf7a;   /* Softer green */
    --color-accent-red: #e05555;     /* Muted red */
    --color-accent-yellow: #e0c055;  /* Warm gold */
  }
}

4. Maintain Depth with Layered Surfaces

In light mode, shadows and borders create depth. In dark mode, shadows become less visible because they blend into the already-dark background. Instead, use elevation through brightness: surfaces that are "higher" (closer to the user) should be slightly lighter than the background. This creates a sense of layering without relying on shadows alone.

/* Elevation system for dark mode */
.card {
  background-color: var(--surface-elevated); /* #1e1e1e — slightly lighter */
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.5);
  border: 1px solid var(--color-border);
}

.modal-overlay {
  background-color: var(--surface-overlay); /* #282828 — noticeably lighter */
  box-shadow: 0 8px 32px rgba(0, 0, 0, 0.8);
}

5. Style Scrollbars and Form Controls

Default browser scrollbars and form elements may look out of place in a dark interface. Use CSS to style scrollbars and apply the color-scheme property to hint the browser's rendering engine.

/* Hint the browser to render native controls in dark mode */
body.dark-mode {
  color-scheme: dark;
}

/* Custom scrollbar styling for dark mode */
body.dark-mode::-webkit-scrollbar {
  width: 10px;
}

body.dark-mode::-webkit-scrollbar-track {
  background: var(--surface-ground);
}

body.dark-mode::-webkit-scrollbar-thumb {
  background: var(--surface-hover);
  border-radius: 5px;
}

body.dark-mode::-webkit-scrollbar-thumb:hover {
  background: #555555;
}

The color-scheme: dark property tells the browser to render native form controls (checkboxes, radio buttons, select menus, date pickers) in their dark variant, ensuring a cohesive look.

6. Test Images and Brand Assets

Logos and images with transparent backgrounds may become invisible against dark surfaces. Consider providing alternate assets or using CSS filters to adapt images.

/* Invert images that look bad on dark backgrounds */
body.dark-mode .logo-img {
  filter: brightness(0.9) invert(0.1);
}

/* Or use a completely separate dark-mode logo */
body.dark-mode .logo-img {
  content: url('/assets/logo-dark.svg');
}

For inline SVG icons, you can simply change their fill color using CSS custom properties, which automatically adapt to your theme.

7. Smooth Transitions Between Themes

A sudden flash when switching themes is jarring. Apply a short transition to color and background properties. Keep transitions brief (200-400ms) and apply them globally via a utility class or the body element.

/* Global theme transition */
body {
  transition: background-color 0.3s ease, color 0.3s ease;
}

/* You can also use a more comprehensive approach */
*,
*::before,
*::after {
  transition: background-color 0.3s ease,
              color 0.3s ease,
              border-color 0.3s ease,
              box-shadow 0.3s ease;
}

/* But be cautious — avoid transitioning properties on hover interactions */
button:hover {
  transition: transform 0.15s ease; /* Keep interaction transitions separate */
}

A common pitfall is applying the universal transition to all properties, which can make hover effects feel sluggish. Limit the transition to color-related properties and keep interactive transitions separate and snappy.

Advanced Techniques

Using CSS Nesting for Cleaner Dark Mode Code

Modern CSS nesting (supported natively in recent browsers) lets you co-locate dark mode overrides within the same selector block, dramatically improving code organization.

/* CSS Nesting example (native, no preprocessor needed) */
.card {
  background-color: var(--color-bg-secondary);
  border: 1px solid var(--color-border);
  padding: 1.5rem;
  border-radius: 8px;

  /* Nest dark mode override directly inside .card */
  @media (prefers-color-scheme: dark) {
    & {
      background-color: var(--surface-elevated);
      border-color: var(--color-border-dark);
    }
    
    & .card-title {
      color: var(--color-text-primary);
    }
  }
}

Respecting User-Controlled Contrast Preferences

Beyond dark mode, users can also express preferences for contrast levels via the prefers-contrast media query. Combine it with your dark mode system for truly accessible design.

/* Boost contrast in dark mode when user prefers high contrast */
@media (prefers-color-scheme: dark) and (prefers-contrast: high) {
  :root {
    --color-text-primary: #ffffff;      /* Full white */
    --color-text-secondary: #cccccc;   /* Lighter gray */
    --color-border: #888888;           /* More visible borders */
  }
}

@media (prefers-color-scheme: dark) and (prefers-contrast: low) {
  :root {
    --color-text-primary: #cccccc;     /* Dimmer text */
    --color-bg-primary: #0a0a0a;      /* Deeper background */
  }
}

Syncing with JavaScript Frameworks

In React, Vue, or Angular applications, you can pair CSS custom properties with reactive state management for seamless theme switching. Here is a conceptual React example using a context provider:

// React ThemeContext example
import { createContext, useState, useEffect, useContext } from 'react';

const ThemeContext = createContext();

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState(() => {
    const saved = localStorage.getItem('theme');
    if (saved) return saved;
    return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
  });

  useEffect(() => {
    document.body.classList.toggle('dark-mode', theme === 'dark');
    localStorage.setItem('theme', theme);
  }, [theme]);

  const toggleTheme = () => {
    setTheme(prev => prev === 'dark' ? 'light' : 'dark');
  };

  return (
    
      {children}
    
  );
}

export function useTheme() {
  return useContext(ThemeContext);
}

This approach keeps all theme logic in one place and allows any component to access or toggle the theme via the useTheme hook, while the actual CSS swapping still happens through the class-based variable system.

Common Pitfalls and How to Avoid Them

Conclusion

Mastering CSS dark mode is a blend of thoughtful design and clean engineering. The technical foundation—prefers-color-scheme, CSS custom properties, and class-based toggles—is straightforward. The real craftsmanship lies in the details: choosing the right shade of dark gray for your backgrounds, desaturating accent colors, building an elevation system with layered surfaces, and respecting user preferences for contrast and manual control.

Start with a robust variable system, implement a hybrid automatic-plus-manual toggle pattern, and then iterate on the subtle visual refinements that make dark mode feel native rather than an afterthought. Test across devices, solicit user feedback, and treat dark mode as a first-class design target rather than a feature checkbox. When done well, it significantly improves user comfort, accessibility, and the overall perception of your product's quality.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles