Dark mode has gone from novelty to expectation. Users assume your application supports it, and they notice when it does not. But implementing dark mode correctly is more nuanced than inverting colors.

This guide covers everything from color theory to implementation patterns that create a genuinely good dark mode experience.

Dark Mode Good dark mode is designed, not just inverted

Why Dark Mode Matters

Benefit Description
Eye strain reduction Less bright light in low-light environments
Battery savings OLED screens use less power with dark pixels
User preference Many users simply prefer it
Accessibility Some visual impairments benefit from reduced glare
Professional expectation Modern apps are expected to support it

The Color Theory

Dark mode is not about inverting light mode. Colors behave differently on dark backgrounds.

Key Principles

1. Reduce, Do not Invert Contrast

Pure white (#FFFFFF) on pure black (#000000) creates harsh contrast that causes eye strain:

/* Bad: maximum contrast */
.dark {
  background: #000000;
  color: #FFFFFF;  /* 21:1 contrast - too harsh */
}

/* Good: reduced contrast */
.dark {
  background: #121212;
  color: #E0E0E0;  /* ~15:1 contrast - easier on eyes */
}

2. Use Elevated Surfaces, Not Shadows

In light mode, shadows create depth. In dark mode, use lighter surfaces:

/* Light mode: shadows for depth */
.card-light {
  background: #FFFFFF;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

/* Dark mode: surface elevation */
.card-dark {
  background: #1E1E1E;  /* Lighter than base #121212 */
  box-shadow: none;     /* Shadows do not show on dark */
}

3. Desaturate Colors

Saturated colors vibrate against dark backgrounds:

/* Light mode */
.button-primary-light {
  background: #2563EB;  /* Saturated blue */
}

/* Dark mode: desaturated */
.button-primary-dark {
  background: #3B82F6;  /* Lighter, less saturated */
}

Color System for Dark Mode

Build a semantic color system:

:root {
  /* Light mode (default) */
  --color-bg-base: #FFFFFF;
  --color-bg-elevated: #F9FAFB;
  --color-bg-overlay: rgba(0, 0, 0, 0.5);

  --color-text-primary: #111827;
  --color-text-secondary: #6B7280;
  --color-text-muted: #9CA3AF;

  --color-border: #E5E7EB;
  --color-border-strong: #D1D5DB;

  --color-primary: #2563EB;
  --color-primary-hover: #1D4ED8;

  --color-success: #059669;
  --color-warning: #D97706;
  --color-error: #DC2626;
}

[data-theme="dark"] {
  /* Dark mode overrides */
  --color-bg-base: #121212;
  --color-bg-elevated: #1E1E1E;
  --color-bg-overlay: rgba(0, 0, 0, 0.7);

  --color-text-primary: #E0E0E0;
  --color-text-secondary: #A0A0A0;
  --color-text-muted: #707070;

  --color-border: #2E2E2E;
  --color-border-strong: #404040;

  --color-primary: #3B82F6;
  --color-primary-hover: #60A5FA;

  --color-success: #10B981;
  --color-warning: #FBBF24;
  --color-error: #F87171;
}

Implementation Approaches

/* Define tokens */
:root {
  --bg: #FFFFFF;
  --text: #1A1A1A;
}

[data-theme="dark"] {
  --bg: #121212;
  --text: #E0E0E0;
}

/* Use tokens */
body {
  background: var(--bg);
  color: var(--text);
}

System Preference Detection

/* Match system preference */
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #121212;
    --text: #E0E0E0;
  }
}

JavaScript Theme Toggle

// Theme provider
type Theme = 'light' | 'dark' | 'system'

function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>(() => {
    // Check localStorage, fallback to 'system'
    return (localStorage.getItem('theme') as Theme) || 'system'
  })

  useEffect(() => {
    const root = document.documentElement

    // Remove existing theme
    root.removeAttribute('data-theme')

    if (theme === 'system') {
      // Let CSS @media handle it
      return
    }

    root.setAttribute('data-theme', theme)
    localStorage.setItem('theme', theme)
  }, [theme])

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

Combining System + User Preference

function useTheme() {
  const [userPreference, setUserPreference] = useState<Theme>(() =>
    (localStorage.getItem('theme') as Theme) || 'system'
  )

  const systemPreference = useMediaQuery('(prefers-color-scheme: dark)')

  const resolvedTheme = useMemo(() => {
    if (userPreference === 'system') {
      return systemPreference ? 'dark' : 'light'
    }
    return userPreference
  }, [userPreference, systemPreference])

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

  return {
    theme: userPreference,
    resolvedTheme,
    setTheme: (theme: Theme) => {
      setUserPreference(theme)
      localStorage.setItem('theme', theme)
    }
  }
}

Theme Toggle Component

function ThemeToggle() {
  const { theme, setTheme } = useTheme()

  return (
    <div role="radiogroup" aria-label="Theme selection">
      <button
        role="radio"
        aria-checked={theme === 'light'}
        onClick={() => setTheme('light')}
      >
        <SunIcon /> Light
      </button>
      <button
        role="radio"
        aria-checked={theme === 'dark'}
        onClick={() => setTheme('dark')}
      >
        <MoonIcon /> Dark
      </button>
      <button
        role="radio"
        aria-checked={theme === 'system'}
        onClick={() => setTheme('system')}
      >
        <ComputerIcon /> System
      </button>
    </div>
  )
}

Handling Images and Media

Image Considerations

Some images need dark mode variants:

<!-- Using picture element -->
<picture>
  <source
    srcset="logo-dark.svg"
    media="(prefers-color-scheme: dark)"
  />
  <img src="logo-light.svg" alt="Logo" />
</picture>

<!-- Or with CSS -->
<img
  src="logo-light.svg"
  class="logo"
  alt="Logo"
/>

<style>
[data-theme="dark"] .logo {
  filter: invert(1);
  /* Or use content: url(logo-dark.svg) */
}
</style>

Dimming Images

Reduce image brightness in dark mode:

[data-theme="dark"] img:not([data-no-dim]) {
  filter: brightness(0.9);
}

Background Images

.hero {
  background-image: url('hero-light.jpg');
}

[data-theme="dark"] .hero {
  background-image: url('hero-dark.jpg');
}

Preventing Flash

The "flash of wrong theme" on page load is jarring. Prevent it:

Inline Script in Head

<head>
  <script>
    // Run before body renders
    (function() {
      const stored = localStorage.getItem('theme')
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches

      if (stored === 'dark' || (stored === 'system' && systemDark) || (!stored && systemDark)) {
        document.documentElement.setAttribute('data-theme', 'dark')
      }
    })()
  </script>
</head>

With Next.js

// _document.tsx
export default function Document() {
  return (
    <Html>
      <Head />
      <body>
        <script
          dangerouslySetInnerHTML={{
            __html: `
              (function() {
                const stored = localStorage.getItem('theme')
                const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches
                if (stored === 'dark' || (!stored && systemDark)) {
                  document.documentElement.setAttribute('data-theme', 'dark')
                }
              })()
            `
          }}
        />
        <Main />
        <NextScript />
      </body>
    </Html>
  )
}

Component-Level Considerations

Shadows in Dark Mode

.card {
  /* Light mode: shadow for depth */
  box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] .card {
  /* Dark mode: border or lighter background instead */
  box-shadow: none;
  border: 1px solid var(--color-border);
  /* Or */
  background: var(--color-bg-elevated);
}

Form Inputs

input, select, textarea {
  background: var(--color-bg-elevated);
  border: 1px solid var(--color-border);
  color: var(--color-text-primary);
}

/* Browser autofill styling */
input:-webkit-autofill {
  -webkit-box-shadow: 0 0 0 1000px var(--color-bg-elevated) inset;
  -webkit-text-fill-color: var(--color-text-primary);
}

Syntax Highlighting

Code blocks need theme-aware syntax highlighting:

/* Using CSS variables in Prism/Highlight.js themes */
.token.comment {
  color: var(--code-comment);
}

.token.keyword {
  color: var(--code-keyword);
}

:root {
  --code-comment: #6A737D;
  --code-keyword: #D73A49;
}

[data-theme="dark"] {
  --code-comment: #8B949E;
  --code-keyword: #FF7B72;
}

Accessibility Considerations

Contrast Ratios

Dark mode must still meet WCAG contrast requirements:

Element Required Ratio
Body text 4.5:1 minimum
Large text (18pt+) 3:1 minimum
UI components 3:1 minimum

Test your dark mode colors:

// Contrast ratio calculation
function getContrastRatio(color1: string, color2: string): number {
  const l1 = getLuminance(color1)
  const l2 = getLuminance(color2)
  const lighter = Math.max(l1, l2)
  const darker = Math.min(l1, l2)
  return (lighter + 0.05) / (darker + 0.05)
}

Focus Indicators

Ensure focus indicators are visible in both modes:

:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

Testing Dark Mode

Checklist

Test Check
All text readable Sufficient contrast
Images appropriate No jarring bright images
Forms usable Inputs visible, states clear
Focus visible Keyboard navigation works
No flash Theme loads before render
System preference Respects OS setting
Toggle works User can override
Persistence Choice remembered

Automated Testing

// Test both themes
describe('Component', () => {
  test('renders correctly in light mode', () => {
    document.documentElement.removeAttribute('data-theme')
    render(<Component />)
    // assertions
  })

  test('renders correctly in dark mode', () => {
    document.documentElement.setAttribute('data-theme', 'dark')
    render(<Component />)
    // assertions
  })
})

Common Mistakes

1. Pure black backgrounds Use #121212 or similar, not #000000. Pure black is harsh.

2. Inverted colors without adjustment Colors need adjustment for dark backgrounds, not just inversion.

3. Ignoring images Bright images are jarring. Consider dimming or providing variants.

4. Forgetting third-party embeds Embedded content (maps, videos, widgets) may not respect your theme.

5. Flash of wrong theme Blocking script in head prevents this.

6. Not providing user control Always let users override system preference.

Quick Implementation Checklist

  1. Define CSS custom properties for colors
  2. Create light and dark color schemes
  3. Add system preference detection (prefers-color-scheme)
  4. Add user preference toggle with localStorage
  5. Prevent flash with inline head script
  6. Adjust images and media
  7. Test contrast ratios
  8. Test with keyboard navigation
  9. Test preference persistence

Dark mode done well feels invisible — users get what they expect without noticing the implementation. That requires attention to detail beyond just swapping colors.

Comments