Skip to main content
Back to Blog
Design

Dark Mode Done Right: Design Systems for Modern Web Apps

Building dark mode that looks good is harder than it seems. Learn color theory, CSS techniques, and accessibility for dark theme design.

High Mountain Studio
10 min read
Side-by-side comparison of light and dark mode interfaces with proper color contrast

Dark Mode Done Right: Design Systems for Modern Web Apps

Dark mode isn't a trend anymore. It's an expectation. Users want it, operating systems support it natively, and when done well, it can reduce eye strain, save battery on OLED screens, and simply look stunning.

But here's the problem: most dark mode implementations are afterthoughts. Developers flip background-color: black and call it a day, leaving users with harsh contrasts, illegible text, and an experience that feels broken rather than designed.

Let's build dark mode the right way: from color theory fundamentals to production-ready CSS architecture.

Why "Invert Colors" Doesn't Work

The most common mistake is treating dark mode as inverted light mode. It seems logical: white becomes black, black becomes white. But color relationships are more nuanced than simple inversion.

The Problems with Pure Inversion

1. Black backgrounds are too harsh

Pure black (#000000) against pure white (#FFFFFF) creates a contrast ratio of 21:1. While high contrast sounds good, it's actually fatiguing to read. The extreme difference causes "halation," where bright text appears to bleed into the dark background.

2. Color saturation looks wrong

Colors that work on white backgrounds often look garish against dark ones. A pleasant blue (#3B82F6) on white can feel electric and overwhelming on black.

3. Depth and elevation break down

In light mode, shadows create the illusion of elevation. In dark mode, shadows disappear into black backgrounds. You need different techniques to show depth.

4. Brand colors shift perception

Your carefully chosen brand green looks completely different in dark context. The same hex value reads differently against dark versus light backgrounds.

The Color Theory Behind Dark Mode

Understanding a few color theory principles will dramatically improve your dark mode implementation.

Luminance vs. Lightness

Luminance is the perceived brightness of a color by the human eye. It's not linear. We perceive differences in darker colors more easily than differences in lighter colors.

This means your dark mode color steps should be tighter at the dark end:

/* Light mode grays (even steps work fine) */
--gray-100: #f5f5f5;  /* +10 */
--gray-200: #e5e5e5;  /* +10 */
--gray-300: #d4d4d4;  /* +10 */

/* Dark mode grays (tighter steps at dark end) */
--gray-800: #1f1f1f;  /* Base */
--gray-850: #171717;  /* +3 */
--gray-900: #0f0f0f;  /* +4 */
--gray-950: #080808;  /* +3 */

Color Temperature

Dark mode doesn't mean "black and white." Consider color temperature:

  • Cool dark mode: Slight blue/purple tint (feels modern, tech-focused)
  • Warm dark mode: Slight brown/orange tint (feels cozy, reduces eye strain)
  • True neutral: Pure gray (feels clinical, can be harsh)
/* Cool dark mode */
--background: #0a0a12;
--surface: #12121a;

/* Warm dark mode */
--background: #0f0d0a;
--surface: #1a1814;

/* Neutral dark mode */
--background: #0a0a0a;
--surface: #141414;

Saturation Reduction

Colors need lower saturation in dark mode to avoid overwhelming the eye. A common rule: reduce saturation by 10-20% for dark mode.

/* Light mode: full saturation */
--primary-light: hsl(220, 90%, 56%);

/* Dark mode: reduced saturation */
--primary-dark: hsl(220, 75%, 60%);

Notice the lightness also increases slightly. Dark backgrounds swallow light, so colors need to be pushed brighter.

Building a Dark Mode Color System

Let's build a proper semantic color system that works for both modes.

Base Layer: Core Palette

Start with gray scales for each mode. These form your foundation:

:root {
  /* Light mode grays */
  --gray-50: #fafafa;
  --gray-100: #f5f5f5;
  --gray-200: #e5e5e5;
  --gray-300: #d4d4d4;
  --gray-400: #a3a3a3;
  --gray-500: #737373;
  --gray-600: #525252;
  --gray-700: #404040;
  --gray-800: #262626;
  --gray-900: #171717;
  --gray-950: #0a0a0a;
}

[data-theme="dark"] {
  /* In dark mode, the scale conceptually inverts */
  --gray-50: #0a0a0a;
  --gray-100: #141414;
  --gray-200: #1f1f1f;
  --gray-300: #2e2e2e;
  --gray-400: #404040;
  --gray-500: #525252;
  --gray-600: #737373;
  --gray-700: #a3a3a3;
  --gray-800: #d4d4d4;
  --gray-900: #e5e5e5;
  --gray-950: #fafafa;
}

Semantic Layer: Meaningful Names

Now map those primitives to semantic tokens that describe their purpose:

:root {
  /* Background colors */
  --color-background: var(--gray-50);
  --color-surface: #ffffff;
  --color-surface-elevated: #ffffff;
  
  /* Text colors */
  --color-text-primary: var(--gray-900);
  --color-text-secondary: var(--gray-600);
  --color-text-tertiary: var(--gray-400);
  
  /* Border colors */
  --color-border: var(--gray-200);
  --color-border-hover: var(--gray-300);
  
  /* Interactive colors */
  --color-primary: hsl(220, 90%, 56%);
  --color-primary-hover: hsl(220, 90%, 48%);
}

[data-theme="dark"] {
  /* Background colors - note: not pure black */
  --color-background: var(--gray-100);  /* #141414, not #000 */
  --color-surface: var(--gray-200);      /* #1f1f1f */
  --color-surface-elevated: var(--gray-300); /* #2e2e2e */
  
  /* Text colors - note: not pure white */
  --color-text-primary: var(--gray-900);  /* #e5e5e5, not #fff */
  --color-text-secondary: var(--gray-700);
  --color-text-tertiary: var(--gray-500);
  
  /* Border colors */
  --color-border: var(--gray-300);
  --color-border-hover: var(--gray-400);
  
  /* Interactive colors - adjusted for dark */
  --color-primary: hsl(220, 80%, 65%);
  --color-primary-hover: hsl(220, 80%, 72%);
}

Component Layer: Specific Applications

For complex components, you might need component-specific tokens:

:root {
  --card-background: var(--color-surface);
  --card-border: var(--color-border);
  --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}

[data-theme="dark"] {
  --card-background: var(--color-surface);
  --card-border: var(--color-border);
  /* Shadows need adjustment in dark mode */
  --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.4);
}

Elevation and Depth in Dark Mode

In light mode, you show elevation with shadows. In dark mode, you show elevation with lighter backgrounds.

The Material Design Approach

Google's Material Design popularized this concept: as surfaces rise, they get lighter in dark mode.

[data-theme="dark"] {
  --elevation-0: #121212;  /* Base layer */
  --elevation-1: #1e1e1e;  /* Cards, dialogs at rest */
  --elevation-2: #232323;  /* Raised cards */
  --elevation-3: #272727;  /* Navigation drawers */
  --elevation-4: #2c2c2c;  /* Modals, popovers */
}

Each level is slightly lighter, creating visual hierarchy without relying on shadows.

Combining with Subtle Shadows

You can still use shadows in dark mode. They just need to be more subtle and work with the elevation system:

[data-theme="dark"] {
  --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.5);
  --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.5);
  --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.6);
}

The higher opacity compensates for the darker environment.

Implementing the Theme Switch

Now let's implement the actual switching mechanism.

CSS Custom Properties + JavaScript

The most flexible approach uses CSS custom properties with a JavaScript toggle:

// ThemeProvider.tsx
'use client';

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

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

const ThemeContext = createContext<{
  theme: Theme;
  setTheme: (theme: Theme) => void;
}>({ theme: 'system', setTheme: () => {} });

export function ThemeProvider({ children }: { children: React.ReactNode }) {
  const [theme, setTheme] = useState<Theme>('system');

  useEffect(() => {
    // Check for saved preference
    const saved = localStorage.getItem('theme') as Theme;
    if (saved) {
      setTheme(saved);
    }
  }, []);

  useEffect(() => {
    const root = document.documentElement;
    
    if (theme === 'system') {
      // Follow system preference
      const systemDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
      root.setAttribute('data-theme', systemDark ? 'dark' : 'light');
    } else {
      root.setAttribute('data-theme', theme);
    }
    
    localStorage.setItem('theme', theme);
  }, [theme]);

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

export const useTheme = () => useContext(ThemeContext);

Respecting System Preference

Always support the prefers-color-scheme media query for users who haven't explicitly chosen:

/* Default to light */
:root {
  --color-background: #ffffff;
  --color-text: #171717;
}

/* System preference */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-background: #141414;
    --color-text: #e5e5e5;
  }
}

/* Explicit dark mode override */
[data-theme="dark"] {
  --color-background: #141414;
  --color-text: #e5e5e5;
}

Preventing Flash of Wrong Theme

Nothing ruins the dark mode experience like a blinding white flash on page load. Prevent it:

// In your root layout, add this script BEFORE any content
<head>
  <script dangerouslySetInnerHTML={{
    __html: `
      (function() {
        const theme = localStorage.getItem('theme');
        if (theme === 'dark' || 
            (theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
            (!theme && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
          document.documentElement.setAttribute('data-theme', 'dark');
        }
      })();
    `,
  }} />
</head>

This runs synchronously before the page renders, preventing flash.

Accessibility Considerations

Dark mode introduces specific accessibility challenges.

Contrast Requirements Remain

WCAG contrast requirements don't change for dark mode:

  • 4.5:1 for normal text
  • 3:1 for large text and UI components

Test your dark mode palette:

Background: #141414
Text: #e5e5e5
Contrast ratio: 12.6:1 ✅

Background: #141414  
Muted text: #525252
Contrast ratio: 3.3:1 ✅ (for large text only)

Focus Indicators

Focus indicators that work in light mode might disappear in dark mode:

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

/* Make sure it's visible in dark mode too */
[data-theme="dark"] :focus-visible {
  outline: 2px solid var(--color-primary);
  /* Primary color was adjusted for dark, so this works */
}

Image Handling

Some images look wrong in dark mode. Consider:

/* Reduce image brightness in dark mode */
[data-theme="dark"] img {
  filter: brightness(0.9) contrast(1.05);
}

/* Invert diagrams/illustrations */
[data-theme="dark"] .invertible {
  filter: invert(1) hue-rotate(180deg);
}

Testing Your Dark Mode

Build a testing checklist:

Visual Checklist

  • [ ] No pure black backgrounds (use #121212 or similar)
  • [ ] No pure white text (use #e5e5e5 or similar)
  • [ ] Cards show elevation through lighter backgrounds
  • [ ] Shadows are visible but subtle
  • [ ] Brand colors look intentional, not washed out
  • [ ] Focus states are clearly visible
  • [ ] Form inputs have visible borders

Technical Checklist

  • [ ] No flash of wrong theme on page load
  • [ ] Theme persists across page navigation
  • [ ] Theme persists across sessions (localStorage)
  • [ ] System preference is respected when no preference is set
  • [ ] Theme toggle is accessible via keyboard
  • [ ] Images render appropriately

Accessibility Checklist

  • [ ] All text meets contrast requirements
  • [ ] Focus indicators are visible
  • [ ] Interactive elements have visible boundaries
  • [ ] Status colors (error, success) are distinguishable

Common Pitfalls to Avoid

1. Forgetting states Hover, focus, active, and disabled states all need dark mode variants. Build your design system to include all states.

2. Hard-coded colors If you have color: #333 scattered through your CSS, dark mode will be a nightmare. Use semantic tokens from the start.

3. Third-party component conflicts Libraries like date pickers, modals, or rich text editors often have their own styles. Plan for overriding their dark mode implementations.

4. SVG icons SVGs with hard-coded fill colors won't adapt. Use currentColor:

<svg fill="currentColor" viewBox="0 0 24 24">
  <path d="..." />
</svg>

5. Assuming users want dark everywhere Some content (photos, marketing pages) might work better in light mode. Consider per-section preferences or at least test thoroughly.


Build Your Design System Right

Dark mode is one piece of a comprehensive design system. When built correctly, with semantic tokens, proper elevation, and accessibility baked in, it's maintainable and scalable.

At High Mountain Studio, we build design systems that handle themes, responsive design, and component variants systematically. If you're struggling with design system architecture or want to implement dark mode properly, reach out for a consultation.

Dark ModeDesign SystemsCSS VariablesColor TheoryUI DesignTailwind CSSTheme SwitchingAccessibilityUser Preferenceprefers-color-scheme