Skip to content
Journal

Design · UI/UX

Dark Mode Implementation — A Complete Developer's Guide for 2026

Dark mode is expected by users, but implementing it correctly involves more than swapping colors. Here's how to build a robust, accessible dark mode that users will love.

Anurag Verma

Anurag Verma

9 min read

Dark Mode Implementation — A Complete Developer's Guide for 2026

Sponsored

Share

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 takes more thought than inverting colors — it changes how contrast, shadows, and saturation all need to behave.

This guide covers everything from color theory to implementation patterns that create a genuinely good dark mode experience. Pair it with our accessibility and WCAG compliance guide and, if you’re building this on top of a component library, our design systems overview for how theming fits into a broader token system.

Dark Mode Good dark mode is designed, not just inverted

Why Dark Mode Matters

BenefitDescription
Eye strain reductionLess bright light in low-light environments
Battery savingsOLED screens use less power with dark pixels
User preferenceMany users simply prefer it
AccessibilitySome visual impairments benefit from reduced glare
Professional expectationModern 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>
  )
}

Native Browser UI and Print Styles

The color-scheme Property

Browsers render their own UI, scrollbars, the <select> dropdown arrow, checkboxes, date pickers, using the light-mode palette by default, even after your CSS has switched everything else to dark. This is the step most dark mode implementations miss.

:root {
  color-scheme: light dark;
}

[data-theme="dark"] {
  color-scheme: dark;
}

Setting color-scheme tells the browser which native rendering to use for its own chrome so it matches the theme you’ve already built. Skip it, and users get a jarring light-styled scrollbar sitting inside an otherwise dark page. You can also declare a default at the document level with <meta name="color-scheme" content="light dark"> in the <head>, though the CSS property takes precedence once your script sets data-theme.

A page that looks correct in dark mode on screen still needs a light background when someone prints it or saves it as a PDF. Browsers do not automatically revert dark backgrounds for print, so a printed dark-mode page burns through ink and can clip against white paper margins. Add a dedicated print stylesheet that resets your color tokens back to the light palette regardless of the active data-theme:

@media print {
  :root,
  [data-theme="dark"] {
    --color-bg-base: #FFFFFF;
    --color-text-primary: #000000;
  }
}

This is a small addition, but it’s the one users notice immediately the first time they hit Ctrl+P on a dark-themed page.

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:

ElementRequired Ratio
Body text4.5:1 minimum
Large text (18pt+)3:1 minimum
UI components3: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

TestCheck
All text readableSufficient contrast
Images appropriateNo jarring bright images
Forms usableInputs visible, states clear
Focus visibleKeyboard navigation works
No flashTheme loads before render
System preferenceRespects OS setting
Toggle worksUser can override
PersistenceChoice 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.

Frequently asked questions

Should dark mode use pure black backgrounds?
No. Pure black (#000000) with pure white text creates harsh, high contrast that causes eye strain. Use a dark gray like #121212 with off-white text around #E0E0E0 instead, which is easier to read for extended periods.
How do I stop the flash of the wrong theme on page load?
Add an inline script in the document head that reads the stored theme preference (or the system preference) and sets the theme attribute before the page body renders. Waiting until a React effect runs is too late and causes a visible flicker.
Why do my scrollbars and form controls stay light in dark mode?
Browsers render native UI like scrollbars and form control chrome using the light-mode palette unless you tell them otherwise. Set the color-scheme CSS property to dark (or light dark) so native browser elements match your custom theme.
Do shadows work the same way in dark mode?
No. Shadows create depth against light backgrounds but barely show up against dark ones. Use elevated (lighter) surface colors instead of box-shadow to signal that an element sits above the background in dark mode.
What is the most commonly skipped step in dark mode implementations?
Print styles. Browsers don't automatically revert a dark theme when a user prints a page or saves it as a PDF, so a page without a dedicated print stylesheet prints with a dark background and wastes ink.

Sponsored

Sponsored

Discussion

Join the conversation.

Comments are powered by GitHub Discussions. Sign in with your GitHub account to leave a comment.

Sponsored