Dark Mode and Color Schemes

Introduction

Dark mode is now a common user expectation across apps and websites. A good dark theme is not just color inversion—it requires readable contrast, balanced surfaces, and consistent component states. This chapter covers practical dark mode architecture with CSS variables, system preference support, and reliable fallback strategies.

Prerequisites

  • Basic CSS custom properties knowledge
  • Familiarity with color and contrast fundamentals
  • Basic media query usage

Dark Mode Goals

A production-ready dark theme should:

  • preserve readability and hierarchy
  • avoid eye-straining high contrast combinations
  • keep interaction states clear
  • work with both system preference and manual override

Dark mode quality depends on thoughtful token design, not quick color inversion.

Start with Semantic Color Tokens

Define semantic tokens in light mode first:

css
:root {
  --bg: #ffffff;
  --surface: #f8fafc;
  --text: #0f172a;
  --text-muted: #475569;
  --border: #e2e8f0;
  --primary: #2563eb;
  --focus-ring: #93c5fd;
}

Code explanation:

  • tokens represent roles (background/text/border), not specific color names
  • semantic naming makes theme switching far easier than raw literals

Apply Tokens in Components

This keeps components theme-ready without duplicating full style blocks.

System Preference with prefers-color-scheme

Add dark token overrides:

css
@media (prefers-color-scheme: dark) {
  :root {
    --bg: #0b1220;
    --surface: #111827;
    --text: #e5e7eb;
    --text-muted: #94a3b8;
    --border: #1f2937;
    --primary: #60a5fa;
    --focus-ring: #93c5fd;
  }
}

Code explanation:

  • only token values change; component rules stay the same
  • system-level dark preference automatically applies when user enables dark mode

This is the simplest robust dark mode baseline.

color-scheme Property

You can hint browser/native UI styling mode:

css
:root {
  color-scheme: light dark;
}

or force one mode in a scope:

css
.dark-surface {
  color-scheme: dark;
}

Code explanation:

  • informs browser how to style built-in UI parts (form controls, scrollbars in supporting browsers)
  • improves consistency between your theme and native control rendering

Manual Theme Override Pattern

Many apps allow explicit user selection (light/dark/system).

Example with data attribute:

css
:root[data-theme="light"] {
  --bg: #ffffff;
  --surface: #f8fafc;
  --text: #0f172a;
}
 
:root[data-theme="dark"] {
  --bg: #0b1220;
  --surface: #111827;
  --text: #e5e7eb;
}

Then JavaScript can set:

js
document.documentElement.setAttribute("data-theme", "dark");

This approach is predictable and easy to persist in local storage.

Priority Strategy: System vs Manual

A common decision order:

  1. explicit user selection (saved preference)
  2. otherwise follow system prefers-color-scheme
  3. fallback to light mode default

Document this behavior clearly so users understand theme switching logic.

Contrast and Readability in Dark Themes

Common dark mode mistakes:

  • pure black background with pure white text (too harsh)
  • too many low-contrast gray combinations
  • weak border contrast causing merged surfaces

Better pattern:

  • dark navy/charcoal backgrounds
  • off-white text instead of pure white
  • subtle but visible border/surface steps

Always test contrast for body text, secondary text, and interactive states.

Tip

Dark Mode Needs Hierarchy, Not Just Darkness

Use distinct background/surface/border levels to preserve depth and scanability.

Do not reuse light-theme link colors blindly.

css
:root[data-theme="dark"] {
  --primary: #60a5fa;
}
 
a {
  color: var(--primary);
}
 
a:focus-visible {
  outline: 2px solid var(--focus-ring);
  outline-offset: 2px;
}

Ensure links remain distinguishable from body text in both themes.

Shadows and Elevation in Dark Mode

Heavy dark shadows may disappear on dark backgrounds.

In dark UI, depth is often conveyed with:

  • subtle light borders
  • soft overlays
  • lower-opacity shadows with careful tuning

Example:

css
.card {
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
  border: 1px solid var(--border);
}

Balance depth cues without muddy visuals.

Images and Media in Dark Context

Watch out for:

  • logos with transparent dark strokes
  • screenshots designed for light backgrounds
  • low-contrast charts

Potential strategies:

  • theme-specific logo assets
  • neutral media framing background
  • optional mild brightness/contrast adjustments per theme

Do not globally invert images—results are usually poor.

Form Controls and Dark Mode

Form fields should have clear boundaries and readable placeholders:

css
input,
textarea,
select {
  background: var(--surface);
  color: var(--text);
  border: 1px solid var(--border);
}
 
input::placeholder {
  color: var(--text-muted);
}

Pair with strong focus-visible styling in both themes.

Themed Code Blocks

Docs and dev-facing UIs should theme code blocks too:

css
pre,
code {
  background: color-mix(in srgb, var(--surface) 88%, black);
  color: var(--text);
}

If you avoid color-mix, use explicit dark/light tokens for code surfaces.

Testing Strategy

Validate all of these in both themes:

  • body text and headings
  • links and buttons (normal/hover/focus/disabled)
  • form controls and error states
  • cards and overlays
  • tables and muted text

Also test in:

  • low brightness environments
  • different displays
  • real devices (not emulator only)

Common Mistakes

MistakeWhy it hurtsBetter approach
Direct light-to-dark inversionUnbalanced readabilityUse semantic token palettes per theme
Ignoring focus state colors in dark modeKeyboard cues disappearDefine theme-aware focus ring tokens
Weak border contrastComponents blend togetherEnsure clear surface/border step
No manual override optionUser preference frustrationSupport explicit theme selection when possible
Unstyled third-party widgetsTheme inconsistencyAudit and theme embedded UI pieces

Debugging Theme Issues

When dark mode looks wrong:

  1. inspect computed token values at root
  2. verify selector specificity for theme override block
  3. confirm component uses tokens (not hardcoded light values)
  4. test state colors (hover, focus-visible, disabled)
  5. check media assets/logos against dark backgrounds

Most theme bugs come from accidental hardcoded literals.

Mini Exercise

  1. Define semantic light tokens in :root
  2. Add dark overrides with prefers-color-scheme
  3. Refactor one card, one form, and one button group to token-only styling
  4. Add data-theme="dark" manual override support
  5. Compare contrast and state visibility in both modes

FAQ

Should dark mode be pure black background?

Usually no. Slightly lifted dark tones are often more readable and visually balanced.

Is system-based dark mode enough?

It is a good baseline. Many products also provide manual user override for better control.

Why does my dark mode feel “flat”?

Likely insufficient surface/border contrast and unclear elevation cues. Add clearer hierarchy tokens.

Can I use one primary color token for both themes?

Sometimes, but many brands need adjusted lightness/saturation per theme for readability.

Do I need to theme every component from day one?

Start with core tokens and high-traffic components first, then expand coverage iteratively with visual audits.