Designing Dark Mode That Actually Looks Good

Team 6 min read

#design

#ui

#dark-mode

#accessibility

#css

Dark mode is not color inversion. It is a purposeful rethinking of contrast, elevation, and atmosphere under low luminance. Done well, it reduces eye strain, preserves hierarchy, and makes interfaces feel refined. Done poorly, it produces glare, muddy colors, and confusing depth.

This guide covers the design principles, color systems, and front-end patterns that lead to dark mode that actually looks good.

Principles that matter

  • Use near-black, not pure black
    • Prefer backgrounds around L=8–14% lightness (for example, hex around #0A0A0A–#1A1A1A). Pure black (#000) increases halation and makes white text glow.
  • Reduce white; increase contrast sensibly
    • Avoid pure white text. Target high contrast without glare. A common starting point is off-white text around L=90–95%.
  • Elevation by lightening, not by shadow
    • In dark UIs, raised surfaces are lighter than the base; shadows are less effective. Use subtle borders and overlays for separation.
  • Increase spacing and line-height
    • Dark backgrounds reveal crowding faster. Slightly larger line-height and letter-spacing improve readability.
  • Respect accessibility guidelines
    • WCAG contrast: 4.5:1 for body text, 3:1 for large text (18pt/24px or 14pt/18.66px bold), and 3:1 for essential iconography and focus indicators.
  • Tune saturation, not just lightness
    • Colors lose perceived saturation on dark. Increase chroma slightly for brand and semantic colors to maintain clarity.

Build a semantic color system

Design tokens let you style by meaning, not hex codes. Start with semantic roles, not brand values.

Core semantics:

  • background: app base
  • surface: cards, sheets
  • overlay: modals, popovers
  • text: primary, secondary, muted, inverse
  • border: subtle, strong
  • focus: visible outline
  • brand: primary, secondary
  • status: success, warning, danger, info

Example token map:

  • Dark background: very dark neutral
  • Dark surface: one or two steps lighter than background
  • Borders: low-opacity light on dark (or low-opacity dark on light)
  • Text primary: soft white; text secondary: reduced alpha or lower lightness
  • Accent colors: slightly higher chroma in dark mode

If supported, OKLCH provides perceptually uniform control. Use hex fallbacks first, then OKLCH.

  • background: fallback #0D0E10; oklch(0.12 0.01 270)
  • surface: fallback #14161A; oklch(0.16 0.01 270)
  • text/primary: fallback #E6E8EB; oklch(0.86 0.02 260)
  • text/secondary: fallback #B3B7BD; oklch(0.73 0.02 260)
  • border/subtle: rgba(255,255,255,0.08)
  • border/strong: rgba(255,255,255,0.16)
  • brand/primary: fallback #7AB7FF; oklch(0.78 0.12 255)
  • success: fallback #6AD39B; oklch(0.78 0.12 150)
  • warning: fallback #FFCC66; oklch(0.83 0.12 85)
  • danger: fallback #FF7A86; oklch(0.72 0.16 20)
  • focus: fallback #8AB4FF; oklch(0.8 0.12 250)

Adjust to match your brand while preserving contrast.

CSS implementation pattern

Use CSS variables with a data-theme attribute. Keep fallbacks first, OKLCH second for progressive enhancement.

:root {
  color-scheme: light dark; /* improves form controls and scrollbars */
}

[data-theme="light"] {
  --bg: #FFFFFF;
  --surface: #F7F8FA;
  --text: #0F1115;
  --text-muted: #4B5563;
  --border-subtle: rgba(0,0,0,0.08);
  --border-strong: rgba(0,0,0,0.16);
  --brand: #2F6FEB;
  --success: #17B26A;
  --warning: #F59E0B;
  --danger: #EF4444;
  --focus: #2F6FEB;
}

[data-theme="dark"] {
  /* fallbacks */
  --bg: #0D0E10;
  --surface: #14161A;
  --text: #E6E8EB;
  --text-muted: #B3B7BD;
  --border-subtle: rgba(255,255,255,0.08);
  --border-strong: rgba(255,255,255,0.16);
  --brand: #7AB7FF;
  --success: #6AD39B;
  --warning: #FFCC66;
  --danger: #FF7A86;
  --focus: #8AB4FF;

  /* OKLCH overrides (supported browsers will use these) */
  --bg: oklch(0.12 0.01 270);
  --surface: oklch(0.16 0.01 270);
  --text: oklch(0.86 0.02 260);
  --text-muted: oklch(0.73 0.02 260);
  --brand: oklch(0.78 0.12 255);
  --success: oklch(0.78 0.12 150);
  --warning: oklch(0.83 0.12 85);
  --danger: oklch(0.72 0.16 20);
  --focus: oklch(0.8 0.12 250);
}

body {
  background: var(--bg);
  color: var(--text);
}

.card {
  background: var(--surface);
  border: 1px solid var(--border-subtle);
  box-shadow: 0 0 0 1px var(--border-subtle); /* crisp edge on dark */
  border-radius: 12px;
}

a {
  color: var(--brand);
}

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

@media (prefers-reduced-motion: no-preference) {
  html {
    transition: color 160ms ease, background-color 160ms ease, border-color 160ms ease;
  }
}

Theme toggling with system defaults and persistence:

<script>
  const mql = window.matchMedia('(prefers-color-scheme: dark)');
  const stored = localStorage.getItem('theme');
  function apply(theme) {
    document.documentElement.dataset.theme = theme;
  }
  apply(stored || (mql.matches ? 'dark' : 'light'));
  mql.addEventListener('change', e => {
    if (!localStorage.getItem('theme')) apply(e.matches ? 'dark' : 'light');
  });
  // Example toggle
  // document.getElementById('themeToggle').onclick = () => {
  //   const next = document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
  //   localStorage.setItem('theme', next);
  //   apply(next);
  // };
</script>

Typography and readability

  • Use slightly larger default size in dark mode (for example, +1px) if your type feels cramped.
  • Increase line-height 0.05–0.1 over light mode defaults.
  • Avoid pure white text; off-white reduces glow while maintaining contrast.
  • Dim secondary text via lower lightness or lower alpha, not both excessively.

Elevation, depth, and separation

  • Prefer lighter surfaces over shadows to indicate elevation.
  • Add subtle 1px borders or divider lines for structure.
  • For floating elements (menus, tooltips), increase surface lightness and add a faint border to reduce blur against near-black backgrounds.

States and interactions

  • Hover states: increase brightness or add a subtle overlay rather than deepening shadows.
  • Active and selected states: use a low-chroma tint or a subtle stroke to avoid heavy blocks.
  • Focus indicators: minimum 3:1 contrast with surrounding colors; ensure visible against varied surfaces.

Data visualization in dark mode

  • Backgrounds: near-black with muted gridlines.
  • Increase color chroma slightly to maintain separation between series.
  • Use lighter gridlines and labels; avoid low-contrast mid-grays.
  • For heatmaps, shift start tones slightly brighter to avoid crushed blacks.

Images, icons, and illustrations

  • Prefer transparent or adaptive assets. Provide dark-mode variants when brand colors or shadows break on dark.
  • Avoid auto-inverting photographs. Manually adjust levels or supply curated dark-mode versions.
  • For monochrome icons, use currentColor with CSS variables; ensure 3:1 contrast minimum.

Gradients and effects

  • Gradients should be shallow and low-luminance to avoid banding. Add film-grain style noise only if necessary and performance allows.
  • Glows and blurs intensify on dark. Use sparingly at low opacity.

Common pitfalls

  • Pure black backgrounds and pure white text causing glare.
  • Borders too low-contrast to separate surfaces.
  • Inverted brand colors that lose recognizability.
  • Shadows doing all the work; they get muddy on dark.
  • Forgetting selection and focus colors.
  • Over-dimming disabled states until they are unreadable.

Testing checklist

  • Contrast
    • Body text ≥ 4.5:1; large text ≥ 3:1; icons and focus ≥ 3:1.
  • System integration
    • Respects prefers-color-scheme and prefers-reduced-motion.
  • States
    • Hover, active, focus, selected, disabled all visible on every surface.
  • Components
    • Inputs, tables, toasts, popovers, tooltips, charts, code blocks verified.
  • Assets
    • Logos and illustrations have dark-mode variants or neutral versions.
  • Devices
    • Test on OLED and LCD; check in bright and dim environments.

Bringing it together

A great dark mode is a distinct theme with its own logic. Use near-black bases, lighter surfaces for elevation, carefully tuned chroma, and semantic tokens to keep implementation consistent. Respect accessibility, test across contexts, and avoid relying on inversion or magic filters. When in doubt, reduce glare, add structure, and measure contrast.

With a solid token system and a small theming layer, you can ship a dark mode that looks refined and feels intentional.