Design.dev design.dev

Dark Mode in CSS

Learn how to implement dark mode using CSS. Master system preferences, CSS Variables, manual theme toggling, optimal color strategies, and accessibility best practices for both light and dark themes.

Introduction

Dark mode reduces eye strain in low-light environments and can save battery life on OLED screens. Modern CSS provides powerful tools to implement dark mode that respects user preferences.

Light Mode

Traditional light backgrounds with dark text. Best for reading in bright environments.

Uses high contrast for readability.

Dark Mode

Dark backgrounds with light text. Reduces eye strain in low-light conditions.

Uses softer colors to prevent glare.

Key Benefits:

  • Reduced eye strain in low-light environments
  • Battery savings on OLED displays
  • Modern, premium user experience
  • Accessibility improvements for light-sensitive users
  • Respects user system preferences

System Preference Detection

Use the prefers-color-scheme media query to automatically adapt to user's system settings.

Basic Implementation

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

/* Dark mode styles */
@media (prefers-color-scheme: dark) {
  body {
    background: #1a1a1a;
    color: #ffffff;
  }
}

Complete Example

/* Light mode (default) */
:root {
  --bg-primary: #ffffff;
  --bg-secondary: #f5f5f5;
  --text-primary: #1a1a1a;
  --text-secondary: #666666;
  --border-color: #e5e5e5;
  --accent: #0066ff;
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --bg-primary: #1a1a1a;
    --bg-secondary: #2d2d2d;
    --text-primary: #ffffff;
    --text-secondary: #a0a0a0;
    --border-color: #404040;
    --accent: #4d94ff;
  }
}

/* Apply variables to elements */
body {
  background: var(--bg-primary);
  color: var(--text-primary);
}

.card {
  background: var(--bg-secondary);
  border: 1px solid var(--border-color);
}

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

Detecting in JavaScript

// Check current preference
const isDarkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;

if (isDarkMode) {
  console.log('User prefers dark mode');
} else {
  console.log('User prefers light mode');
}

// Listen for changes
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');

mediaQuery.addEventListener('change', (e) => {
  if (e.matches) {
    console.log('Switched to dark mode');
  } else {
    console.log('Switched to light mode');
  }
});

CSS Variables Approach

CSS Variables (Custom Properties) provide the most flexible and maintainable way to implement dark mode.

Setting Up Variables

/* Define all color variables */
:root {
  /* Colors */
  --color-primary: #0066ff;
  --color-secondary: #6366f1;
  --color-success: #10b981;
  --color-warning: #f59e0b;
  --color-danger: #ef4444;
  
  /* Backgrounds */
  --bg-page: #ffffff;
  --bg-card: #f9fafb;
  --bg-elevated: #ffffff;
  
  /* Text */
  --text-primary: #1a1a1a;
  --text-secondary: #666666;
  --text-tertiary: #999999;
  
  /* Borders */
  --border-color: #e5e5e5;
  --border-subtle: #f0f0f0;
  
  /* Shadows */
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
}

/* Dark mode overrides */
@media (prefers-color-scheme: dark) {
  :root {
    /* Adjust primary colors for dark mode */
    --color-primary: #4d94ff;
    --color-secondary: #818cf8;
    
    /* Dark backgrounds */
    --bg-page: #0a0a0a;
    --bg-card: #1a1a1a;
    --bg-elevated: #2d2d2d;
    
    /* Light text */
    --text-primary: #ffffff;
    --text-secondary: #a0a0a0;
    --text-tertiary: #666666;
    
    /* Lighter borders for dark mode */
    --border-color: #404040;
    --border-subtle: #2a2a2a;
    
    /* Softer shadows */
    --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
    --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.4);
    --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.5);
  }
}

Component-Level Variables

/* Button component with theme support */
.button {
  --btn-bg: var(--color-primary);
  --btn-text: white;
  --btn-hover: var(--color-primary-dark, #0052cc);
  
  background: var(--btn-bg);
  color: var(--btn-text);
  padding: 0.75rem 1.5rem;
  border: none;
  border-radius: 6px;
  cursor: pointer;
  transition: background 0.2s ease;
}

.button:hover {
  background: var(--btn-hover);
}

/* Variant with different colors in dark mode */
@media (prefers-color-scheme: dark) {
  .button {
    --btn-bg: var(--color-primary);
    --btn-hover: #6ba3ff;
  }
  
  .button-secondary {
    --btn-bg: #2d2d2d;
    --btn-hover: #404040;
  }
}

Semantic Color System

/* Create semantic color tokens */
:root {
  /* Base colors */
  --blue-500: #0066ff;
  --blue-600: #0052cc;
  --blue-400: #3385ff;
  
  --gray-50: #f9fafb;
  --gray-100: #f3f4f6;
  --gray-900: #111827;
  
  /* Semantic tokens reference base colors */
  --color-primary: var(--blue-500);
  --bg-page: var(--gray-50);
  --text-primary: var(--gray-900);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Remap semantic tokens for dark mode */
    --color-primary: var(--blue-400);
    --bg-page: #0a0a0a;
    --text-primary: var(--gray-50);
  }
}

Manual Theme Toggle

Allow users to override system preferences with a manual theme selector.

HTML Structure

<button id="themeToggle" aria-label="Toggle theme">
  <span class="theme-icon light-icon">☀️</span>
  <span class="theme-icon dark-icon">🌙</span>
</button>

CSS with Data Attribute

/* Default light theme */
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
}

/* System dark mode preference */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #1a1a1a;
    --text: #ffffff;
  }
}

/* Manual dark mode override */
[data-theme="dark"] {
  --bg: #1a1a1a;
  --text: #ffffff;
}

/* Manual light mode override */
[data-theme="light"] {
  --bg: #ffffff;
  --text: #1a1a1a;
}

body {
  background: var(--bg);
  color: var(--text);
  transition: background-color 0.3s ease, color 0.3s ease;
}

JavaScript Implementation

// Get saved theme or default to system preference
function getTheme() {
  const savedTheme = localStorage.getItem('theme');
  if (savedTheme) {
    return savedTheme;
  }
  
  // Check system preference
  return window.matchMedia('(prefers-color-scheme: dark)').matches 
    ? 'dark' 
    : 'light';
}

// Apply theme
function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  localStorage.setItem('theme', theme);
  
  // Update toggle button
  updateThemeToggle(theme);
}

// Toggle between themes
function toggleTheme() {
  const currentTheme = getTheme();
  const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
  setTheme(newTheme);
}

// Update button appearance
function updateThemeToggle(theme) {
  const lightIcon = document.querySelector('.light-icon');
  const darkIcon = document.querySelector('.dark-icon');
  
  if (theme === 'dark') {
    lightIcon.style.display = 'none';
    darkIcon.style.display = 'block';
  } else {
    lightIcon.style.display = 'block';
    darkIcon.style.display = 'none';
  }
}

// Initialize theme on page load
document.addEventListener('DOMContentLoaded', () => {
  const theme = getTheme();
  setTheme(theme);
  
  // Add toggle listener
  const toggle = document.getElementById('themeToggle');
  toggle.addEventListener('click', toggleTheme);
});

Alternative: Class-Based Approach

/* Default light theme */
body {
  --bg: #ffffff;
  --text: #1a1a1a;
}

/* Dark theme class */
body.dark-mode {
  --bg: #1a1a1a;
  --text: #ffffff;
}

/* Apply to all elements */
body {
  background: var(--bg);
  color: var(--text);
}
// Simple class toggle
function toggleTheme() {
  document.body.classList.toggle('dark-mode');
  
  // Save preference
  const isDark = document.body.classList.contains('dark-mode');
  localStorage.setItem('theme', isDark ? 'dark' : 'light');
}

// Load saved theme
const savedTheme = localStorage.getItem('theme');
if (savedTheme === 'dark') {
  document.body.classList.add('dark-mode');
}

Interactive Demo

Try toggling between themes:

Sample Content

This is how your content would look in the selected theme. The colors smoothly transition as you switch between modes.

Notice how the background, text, and border colors all adapt to maintain good contrast and readability.

Color Strategies

Choose appropriate colors for light and dark modes to maintain readability and visual hierarchy.

Background Colors

:root {
  /* Light mode - use true white sparingly */
  --bg-page: #fafafa;      /* Softer than pure white */
  --bg-card: #ffffff;
  --bg-elevated: #ffffff;
  --bg-input: #f5f5f5;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Dark mode - avoid pure black */
    --bg-page: #0a0a0a;    /* Near black, easier on eyes */
    --bg-card: #1a1a1a;    /* Slightly lighter for cards */
    --bg-elevated: #2d2d2d; /* Even lighter for elevation */
    --bg-input: #1f1f1f;
  }
}

Text Colors

:root {
  /* Light mode text */
  --text-primary: #1a1a1a;   /* Near black */
  --text-secondary: #666666; /* Medium gray */
  --text-tertiary: #999999;  /* Light gray */
  --text-disabled: #cccccc;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Dark mode text - reduce brightness */
    --text-primary: #e5e5e5;   /* Not pure white */
    --text-secondary: #a0a0a0; /* Medium gray */
    --text-tertiary: #666666;  /* Darker gray */
    --text-disabled: #404040;
  }
}

Color Saturation Adjustments

:root {
  /* Light mode - vibrant colors */
  --color-blue: hsl(210, 100%, 50%);
  --color-green: hsl(140, 80%, 40%);
  --color-red: hsl(0, 80%, 50%);
  --color-purple: hsl(280, 80%, 50%);
}

@media (prefers-color-scheme: dark) {
  :root {
    /* Dark mode - less saturated, higher lightness */
    --color-blue: hsl(210, 80%, 60%);
    --color-green: hsl(140, 60%, 55%);
    --color-red: hsl(0, 70%, 60%);
    --color-purple: hsl(280, 70%, 65%);
  }
}

/* Usage */
.button-primary {
  background: var(--color-blue);
}

.success-message {
  color: var(--color-green);
}

Contrast Ratios

/* Ensure WCAG AA compliance (4.5:1 for normal text) */

/* Light mode */
:root {
  --text-on-white: #1a1a1a;    /* 16.1:1 - Excellent */
  --text-secondary: #666666;    /* 5.74:1 - Good */
  --link-color: #0066ff;        /* 4.54:1 - Passes AA */
}

/* Dark mode */
@media (prefers-color-scheme: dark) {
  :root {
    --text-on-dark: #ffffff;    /* 21:1 - Maximum */
    --text-secondary: #a0a0a0;  /* 6.56:1 - Good */
    --link-color: #4d94ff;      /* 5.12:1 - Passes AA */
  }
}

Color Tips:

  • Avoid pure black (#000000) in dark mode - use near-black (#0a0a0a)
  • Reduce color saturation in dark mode to prevent eye strain
  • Increase lightness of accent colors in dark mode
  • Test contrast ratios with tools like Color Contrast Checker
  • Use elevation (layered backgrounds) instead of shadows in dark mode

Images & Media

Handle images, icons, and media elements appropriately in dark mode.

Adjusting Image Brightness

/* Reduce brightness of images in dark mode */
@media (prefers-color-scheme: dark) {
  img {
    opacity: 0.8;
    transition: opacity 0.3s ease;
  }
  
  img:hover {
    opacity: 1;
  }
  
  /* Alternative: use filter */
  img.photo {
    filter: brightness(0.9) contrast(1.1);
  }
}

Theme-Specific Images

<!-- Using picture element -->
<picture>
  <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="logo-light.svg" alt="Logo">
</picture>
/* CSS approach with background images */
.logo {
  background-image: url('logo-light.svg');
}

@media (prefers-color-scheme: dark) {
  .logo {
    background-image: url('logo-dark.svg');
  }
}

SVG Icons

/* Inline SVG icons inherit color */
.icon {
  width: 24px;
  height: 24px;
  fill: currentColor; /* Inherits text color */
}

/* Or use CSS custom properties */
.icon {
  fill: var(--icon-color);
}

:root {
  --icon-color: #666666;
}

@media (prefers-color-scheme: dark) {
  :root {
    --icon-color: #a0a0a0;
  }
}

Video and iframe Elements

/* Add subtle border in dark mode */
@media (prefers-color-scheme: dark) {
  video,
  iframe {
    border: 1px solid var(--border-color);
    border-radius: 8px;
  }
  
  /* Reduce brightness of background */
  video::backdrop {
    background: rgba(0, 0, 0, 0.95);
  }
}

Invert Filter for Icons

/* Quick fix for dark logos/icons */
@media (prefers-color-scheme: dark) {
  .logo,
  .icon-dark {
    filter: invert(1) hue-rotate(180deg);
  }
  
  /* More subtle approach */
  .illustration {
    filter: invert(0.9) hue-rotate(180deg);
    opacity: 0.9;
  }
}

Accessibility

Ensure your dark mode implementation is accessible to all users.

Color Contrast

/* WCAG AA requires 4.5:1 for normal text, 3:1 for large text */

/* Light mode - good contrast */
:root {
  --text: #1a1a1a;  /* 16.1:1 on white */
  --bg: #ffffff;
}

/* Dark mode - maintain contrast */
@media (prefers-color-scheme: dark) {
  :root {
    /* Don't use pure white - too bright */
    --text: #e5e5e5;  /* 15.3:1 on #0a0a0a */
    --bg: #0a0a0a;
  }
}

/* Links need sufficient contrast too */
a {
  color: var(--link-color);
  text-decoration: underline; /* Help users identify links */
}

:root {
  --link-color: #0066ff;  /* 4.5:1 on white */
}

@media (prefers-color-scheme: dark) {
  :root {
    --link-color: #6ba3ff;  /* 5.2:1 on #0a0a0a */
  }
}

Focus Indicators

/* Ensure focus indicators work in both modes */
:focus-visible {
  outline: 2px solid var(--focus-color);
  outline-offset: 2px;
}

:root {
  --focus-color: #0066ff;
}

@media (prefers-color-scheme: dark) {
  :root {
    --focus-color: #4d94ff;
  }
}

/* Custom focus styles */
button:focus-visible {
  outline: 2px solid var(--focus-color);
  outline-offset: 2px;
  box-shadow: 0 0 0 4px rgba(77, 148, 255, 0.2);
}

Reduced Motion

/* Respect reduced motion preference */
body {
  transition: background-color 0.3s ease, color 0.3s ease;
}

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

High Contrast Mode

/* Windows High Contrast Mode */
@media (prefers-contrast: high) {
  :root {
    --text-primary: CanvasText;
    --bg-primary: Canvas;
    --link-color: LinkText;
    --border-color: CanvasText;
  }
  
  /* Increase border width */
  button,
  input,
  select {
    border-width: 2px;
  }
  
  /* Remove subtle shadows */
  * {
    box-shadow: none !important;
  }
}

Screen Reader Announcements

<!-- Announce theme changes to screen readers -->
<div role="status" aria-live="polite" class="sr-only" id="themeAnnouncement"></div>

<button 
  id="themeToggle"
  aria-label="Toggle between light and dark mode"
  aria-pressed="false">
  Toggle Theme
</button>
function setTheme(theme) {
  document.documentElement.setAttribute('data-theme', theme);
  
  // Update ARIA
  const toggle = document.getElementById('themeToggle');
  toggle.setAttribute('aria-pressed', theme === 'dark');
  
  // Announce to screen readers
  const announcement = document.getElementById('themeAnnouncement');
  announcement.textContent = `${theme} mode activated`;
  
  // Clear announcement after delay
  setTimeout(() => {
    announcement.textContent = '';
  }, 1000);
}

Accessibility Checklist:

  • ✓ Maintain 4.5:1 contrast ratio for text
  • ✓ Maintain 3:1 contrast ratio for UI components
  • ✓ Test with actual users who use dark mode
  • ✓ Ensure focus indicators are visible
  • ✓ Support keyboard navigation
  • ✓ Respect prefers-reduced-motion
  • ✓ Announce theme changes to screen readers
  • ✓ Test in high contrast mode

Best Practices

Guidelines for implementing effective and maintainable dark mode.

Color Selection

✓ Do:

  • Use near-black (#0a0a0a) instead of pure black
  • Reduce saturation of colors in dark mode
  • Increase lightness of accent colors
  • Use elevation (layered backgrounds) for depth
  • Test with color blindness simulators

✗ Avoid:

  • Pure black backgrounds (#000000)
  • Pure white text on dark backgrounds
  • Using the same colors in both modes
  • Heavy shadows in dark mode
  • Oversaturated colors in dark mode

Performance

/* Use CSS Variables for instant switching */
:root {
  --bg: #ffffff;
  --text: #1a1a1a;
}

/* Avoid inline styles - harder to maintain */
/* ❌ Bad */
<div style="background: #ffffff; color: #1a1a1a;">

/* ✓ Good */
<div class="card">

Flash of Incorrect Theme (FOIT)

<!-- Prevent flash by loading theme before render -->
<script>
  // Run immediately, before body renders
  (function() {
    const theme = localStorage.getItem('theme') || 
      (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
    document.documentElement.setAttribute('data-theme', theme);
  })();
</script>

Testing Strategy

// Test dark mode programmatically
function testDarkMode() {
  const tests = [];
  
  // Check contrast ratios
  tests.push({
    name: 'Text contrast',
    pass: checkContrast('.text', 4.5)
  });
  
  // Check all interactive elements
  tests.push({
    name: 'Button focus',
    pass: checkContrast('.button:focus', 3.0)
  });
  
  // Check images
  tests.push({
    name: 'Image visibility',
    pass: checkImageContrast('img')
  });
  
  return tests;
}

// Helper to check contrast
function checkContrast(selector, ratio) {
  const element = document.querySelector(selector);
  const fg = getComputedStyle(element).color;
  const bg = getComputedStyle(element).backgroundColor;
  return calculateContrast(fg, bg) >= ratio;
}

Documentation

/* Document your color system */

/**
 * Color Variables
 * 
 * Light mode uses warm grays and vibrant accent colors
 * Dark mode uses cool near-blacks with desaturated accents
 * All colors tested for WCAG AA compliance
 */

:root {
  /* Primary - Main brand color */
  --color-primary: #0066ff;      /* 4.54:1 on white */
  
  /* Background - Page backgrounds */
  --bg-page: #fafafa;            /* Softer than white */
  --bg-card: #ffffff;            /* True white for cards */
  
  /* Text - Hierarchical text colors */
  --text-primary: #1a1a1a;       /* 16.1:1 - Body text */
  --text-secondary: #666666;     /* 5.74:1 - Captions */
}

Implementation Examples

Complete examples for common scenarios.

Simple Auto Dark Mode

/* Minimal implementation with system preference */
:root {
  color-scheme: light dark; /* Tell browser to use native controls */
}

body {
  background: Canvas;
  color: CanvasText;
}

@media (prefers-color-scheme: dark) {
  body {
    /* Additional dark mode styles */
  }
}

Complete Toggle Implementation

<!DOCTYPE html>
<html lang="en">
<head>
  <meta name="color-scheme" content="light dark">
  <style>
    :root {
      --bg: #ffffff;
      --text: #1a1a1a;
      --card-bg: #f5f5f5;
    }
    
    [data-theme="dark"] {
      --bg: #0a0a0a;
      --text: #e5e5e5;
      --card-bg: #1a1a1a;
    }
    
    body {
      background: var(--bg);
      color: var(--text);
      transition: background-color 0.3s ease;
    }
  </style>
  
  <script>
    // Load theme before render
    (function() {
      const theme = localStorage.getItem('theme') || 'light';
      document.documentElement.setAttribute('data-theme', theme);
    })();
  </script>
</head>
<body>
  <button onclick="toggleTheme()">Toggle Theme</button>
  
  <script>
    function toggleTheme() {
      const html = document.documentElement;
      const current = html.getAttribute('data-theme');
      const next = current === 'dark' ? 'light' : 'dark';
      
      html.setAttribute('data-theme', next);
      localStorage.setItem('theme', next);
    }
  </script>
</body>
</html>

Framework Integration (React)

// useTheme.js
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    document.documentElement.setAttribute('data-theme', theme);
    localStorage.setItem('theme', theme);
  }, [theme]);

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

  return { theme, toggleTheme };
}

// Usage in component
function App() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <button onClick={toggleTheme}>
      {theme === 'dark' ? '🌙' : '☀️'}
    </button>
  );
}

Tailwind CSS Configuration

// tailwind.config.js
module.exports = {
  darkMode: 'class', // or 'media' for system preference
  theme: {
    extend: {
      colors: {
        // Custom colors for dark mode
        primary: {
          light: '#0066ff',
          dark: '#4d94ff',
        }
      }
    }
  }
}

// Usage in HTML
<div class="bg-white dark:bg-gray-900 
            text-gray-900 dark:text-gray-100">
  Content
</div>