Front-end Engineering Lab
PatternsDesign Systems

Dark Mode Implementation

Implement proper dark mode with accessibility and performance in mind

Dark Mode Implementation

Dark mode reduces eye strain in low-light environments and saves battery on OLED screens. Implementing it properly requires careful attention to contrast, colors, and user experience.

Why Dark Mode Matters

Benefits:

  • Reduces eye strain in low light
  • Saves battery on OLED/AMOLED screens (up to 60%)
  • Modern UI expectation
  • Accessibility for light-sensitive users

Statistics:

  • 82% of smartphone users use dark mode
  • 70% prefer dark mode in apps that offer it

Color Strategy

Avoid Pure Black

/* ❌ BAD: Pure black (#000000) */
[data-theme="dark"] {
  --bg: #000000;  /* Too harsh, hard to see shadows */
  --text: #FFFFFF; /* Too much contrast */
}

/* ✅ GOOD: Dark gray backgrounds */
[data-theme="dark"] {
  --bg: #0A0A0A;      /* Softer black */
  --bg-elevated: #1A1A1A;  /* Elevated surfaces */
  --text: #E5E5E5;    /* Softer white */
}

Why? Pure black makes it hard to perceive depth (shadows don't show). Slightly lighter backgrounds create better hierarchy.

Elevation System

/* Light mode: elevation via shadows */
[data-theme="light"] {
  --surface-0: #FFFFFF;
  --surface-1: #FFFFFF;
  --surface-2: #FFFFFF;
  --surface-3: #FFFFFF;
  
  --shadow-1: 0 1px 3px rgba(0,0,0,0.1);
  --shadow-2: 0 4px 6px rgba(0,0,0,0.1);
  --shadow-3: 0 10px 15px rgba(0,0,0,0.1);
}

/* Dark mode: elevation via lightness */
[data-theme="dark"] {
  --surface-0: #0A0A0A;   /* Base */
  --surface-1: #1A1A1A;   /* Raised 1dp */
  --surface-2: #2A2A2A;   /* Raised 2dp */
  --surface-3: #3A3A3A;   /* Raised 3dp */
  
  --shadow-1: 0 1px 3px rgba(0,0,0,0.5);
  --shadow-2: 0 4px 6px rgba(0,0,0,0.5);
  --shadow-3: 0 10px 15px rgba(0,0,0,0.5);
}

/* Usage */
.card {
  background: var(--surface-1);
  box-shadow: var(--shadow-1);
}

.modal {
  background: var(--surface-3);
  box-shadow: var(--shadow-3);
}

De-saturate Colors

// Light mode: Vibrant colors
const lightColors = {
  primary: '#0066CC',    // Bright blue
  success: '#10B981',    // Bright green
  error: '#EF4444',      // Bright red
};

// Dark mode: De-saturated colors
const darkColors = {
  primary: '#4A9EFF',    // Lighter, less saturated blue
  success: '#34D399',    // Lighter green
  error: '#F87171',      // Lighter red
};

Why? Bright, saturated colors on dark backgrounds cause eye strain. Lighter, de-saturated colors are easier on the eyes.

Complete Implementation

1. Define Color Tokens

// tokens/colors.ts
export const colors = {
  light: {
    // Backgrounds
    bg: {
      default: '#FFFFFF',
      subtle: '#F9FAFB',
      muted: '#F3F4F6',
    },
    
    // Text
    text: {
      default: '#111827',
      muted: '#6B7280',
      subtle: '#9CA3AF',
    },
    
    // Borders
    border: {
      default: '#E5E7EB',
      hover: '#D1D5DB',
    },
    
    // Brand
    primary: '#0066CC',
    secondary: '#6B7280',
    
    // Feedback
    success: '#10B981',
    warning: '#F59E0B',
    error: '#EF4444',
    info: '#0066CC',
  },
  
  dark: {
    // Backgrounds
    bg: {
      default: '#0A0A0A',
      subtle: '#1A1A1A',
      muted: '#2A2A2A',
    },
    
    // Text
    text: {
      default: '#E5E5E5',
      muted: '#A3A3A3',
      subtle: '#737373',
    },
    
    // Borders
    border: {
      default: '#404040',
      hover: '#525252',
    },
    
    // Brand
    primary: '#4A9EFF',
    secondary: '#A3A3A3',
    
    // Feedback
    success: '#34D399',
    warning: '#FBBF24',
    error: '#F87171',
    info: '#4A9EFF',
  },
};

2. Apply with CSS Variables

/* global.css */
:root {
  --color-bg-default: #FFFFFF;
  --color-text-default: #111827;
  --color-primary: #0066CC;
  /* ... */
}

[data-theme="dark"] {
  --color-bg-default: #0A0A0A;
  --color-text-default: #E5E5E5;
  --color-primary: #4A9EFF;
  /* ... */
}

/* Smooth transition */
* {
  transition: background-color 0.2s ease, color 0.2s ease;
}

3. Theme Provider

// components/DarkModeProvider.tsx
'use client';

import { createContext, useContext, useEffect, useState } from 'react';

type Theme = 'light' | 'dark' | 'system';

interface ThemeContextValue {
  theme: Theme;
  setTheme: (theme: Theme) => void;
  resolvedTheme: 'light' | 'dark';
}

const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);

export function DarkModeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setThemeState] = useState<Theme>('system');
  const [mounted, setMounted] = useState(false);

  // Resolve system preference
  const systemTheme = mounted && window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
  
  const resolvedTheme = theme === 'system' ? systemTheme : theme;

  // Apply theme
  useEffect(() => {
    if (!mounted) return;
    
    document.documentElement.setAttribute('data-theme', resolvedTheme);
    
    // Update meta theme-color
    const metaTheme = document.querySelector('meta[name="theme-color"]');
    if (metaTheme) {
      metaTheme.setAttribute(
        'content',
        resolvedTheme === 'dark' ? '#0A0A0A' : '#FFFFFF'
      );
    }
  }, [resolvedTheme, mounted]);

  // Load saved preference
  useEffect(() => {
    const saved = localStorage.getItem('theme') as Theme;
    if (saved) {
      setThemeState(saved);
    }
    setMounted(true);
  }, []);

  // Listen to system changes
  useEffect(() => {
    if (!mounted) return;
    
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    
    const handler = () => {
      if (theme === 'system') {
        // Re-render with new system theme
        setThemeState('system');
      }
    };
    
    mediaQuery.addEventListener('change', handler);
    return () => mediaQuery.removeEventListener('change', handler);
  }, [theme, mounted]);

  const setTheme = (newTheme: Theme) => {
    setThemeState(newTheme);
    localStorage.setItem('theme', newTheme);
  };

  if (!mounted) {
    return <>{children}</>;
  }

  return (
    <ThemeContext.Provider value={{ theme, setTheme, resolvedTheme }}>
      {children}
    </ThemeContext.Provider>
  );
}

export function useDarkMode() {
  const context = useContext(ThemeContext);
  if (!context) {
    throw new Error('useDarkMode must be used within DarkModeProvider');
  }
  return context;
}

4. Theme Toggle

// components/DarkModeToggle.tsx
'use client';

import { useDarkMode } from './DarkModeProvider';
import { SunIcon, MoonIcon } from '@/components/icons';

export function DarkModeToggle() {
  const { theme, setTheme, resolvedTheme } = useDarkMode();

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

  return (
    <button
      onClick={toggleTheme}
      aria-label={`Switch to ${resolvedTheme === 'dark' ? 'light' : 'dark'} mode`}
      className="theme-toggle"
    >
      {resolvedTheme === 'dark' ? <SunIcon /> : <MoonIcon />}
    </button>
  );
}

5. Prevent Flash

// app/layout.tsx
export default function RootLayout({ children }: Props) {
  return (
    <html lang="en" suppressHydrationWarning>
      <head>
        <meta name="theme-color" content="#FFFFFF" />
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const theme = localStorage.getItem('theme');
                const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
                const resolvedTheme = theme === 'system' || !theme ? systemTheme : theme;
                
                document.documentElement.setAttribute('data-theme', resolvedTheme);
                
                const metaTheme = document.querySelector('meta[name="theme-color"]');
                if (metaTheme) {
                  metaTheme.setAttribute('content', resolvedTheme === 'dark' ? '#0A0A0A' : '#FFFFFF');
                }
              })();
            `,
          }}
        />
      </head>
      <body>
        <DarkModeProvider>
          {children}
        </DarkModeProvider>
      </body>
    </html>
  );
}

Image Handling

// Invert images in dark mode
.image-invert {
  filter: brightness(0.8);
}

[data-theme="dark"] .image-invert {
  filter: brightness(0.8) invert(1);
}

// Serve different images
function Logo() {
  const { resolvedTheme } = useDarkMode();
  
  return (
    <img
      src={resolvedTheme === 'dark' ? '/logo-dark.svg' : '/logo-light.svg'}
      alt="Logo"
    />
  );
}

Accessibility

Maintain Contrast Ratios

Text on background:
- Normal text: 4.5:1 minimum (WCAG AA)
- Large text: 3:1 minimum (WCAG AA)
- Enhanced: 7:1 (WCAG AAA)

Test both themes!

Tools

  • Contrast Checker: WebAIM, Stark
  • Browser DevTools: Chrome/Firefox contrast tools

Don't Rely on Color Alone

// ❌ BAD: Color only
<div className="text-error">Error occurred</div>

// ✅ GOOD: Color + icon + text
<div className="text-error">
  <ErrorIcon />
  <span>Error: Invalid input</span>
</div>

Testing Dark Mode

// Test both themes
describe('Button', () => {
  it('has sufficient contrast in light mode', () => {
    render(
      <DarkModeProvider theme="light">
        <Button>Click me</Button>
      </DarkModeProvider>
    );
    
    const button = screen.getByRole('button');
    // Check contrast ratio >= 4.5:1
  });

  it('has sufficient contrast in dark mode', () => {
    render(
      <DarkModeProvider theme="dark">
        <Button>Click me</Button>
      </DarkModeProvider>
    );
    
    const button = screen.getByRole('button');
    // Check contrast ratio >= 4.5:1
  });
});

Best Practices

  1. Test Both Themes: Every component in light and dark
  2. Maintain Contrast: WCAG AA minimum (4.5:1)
  3. Respect System: Default to prefers-color-scheme
  4. Smooth Transitions: Animate theme changes
  5. No Flash: Inline script before render
  6. Soft Black: Use #0A0A0A, not #000000
  7. De-saturate Colors: Lighter colors in dark mode
  8. Elevation: Lighter surfaces = higher elevation
  9. Test Images: Some may need inversion
  10. Meta Theme: Update mobile browser chrome

Common Pitfalls

Pure black (#000): Too harsh
Soft black (#0A0A0A)

Same colors: Light theme colors in dark
De-saturated, lighter colors

No elevation: Flat surfaces
Lighter = elevated

Flash on load: Wrong theme briefly shown
Inline script to prevent flash

Ignored images: Look bad in dark mode
Different images or filters

Dark mode is now a baseline expectation—implement it thoughtfully from the start!

On this page