Design Engineering
Design & Architecture

Dark Mode & Theming

Design and implement theme-aware interfaces — token architecture, system detection, persistence, toggle patterns, testing, and visual asset considerations.

Dark mode is not simply inverting colours. It is a separate visual design that must maintain readability, preserve brand intent, and respect user preference across every component in the system. A well-implemented theme system makes dark mode feel inevitable rather than retrofitted.

Token Architecture for Theming

The foundation of any theme system is a token architecture that separates primitive values from semantic roles. Do not hard-code colours per theme. Define semantic tokens that reference different primitives in each theme.

Layer structure

LayerPurposeExample
Primitive tokensRaw colour values (OKLCH)--blue-500: oklch(0.42 0.17 265)
Semantic tokensRole-based references--color-primary: var(--blue-500)
Component tokensComponent-specific overrides--button-primary-bg: var(--color-primary)

Token categories for theming

Semantic token structure
:root {
  /* Backgrounds — reversed in dark mode */
  --color-background: oklch(0.985 0.001 260);
  --color-card: oklch(0.975 0.002 260);
  --color-popover: oklch(0.99 0.001 260);

  /* Foreground — high contrast in light, slightly adjusted in dark */
  --color-foreground: oklch(0.15 0.01 260);

  /* Brand colours — same hue, adjusted lightness and chroma per theme */
  --color-primary: oklch(0.42 0.17 265);
  --color-primary-foreground: oklch(0.99 0.001 260);

  /* Muted surfaces — subtle backgrounds for disabled or secondary areas */
  --color-muted: oklch(0.95 0.003 260);
  --color-muted-foreground: oklch(0.50 0.01 260);

  /* Accent — surfaces that need emphasis without being primary */
  --color-accent: oklch(0.60 0.13 245);
  --color-accent-foreground: oklch(0.99 0.001 260);

  /* Borders and rings */
  --color-border: oklch(0.88 0.006 260);
  --color-ring: var(--color-primary);
}

See Design System for the complete token naming convention and colour system documentation.

Dark mode overrides

In dark mode, surface colours deepen, foreground colours brighten, and accent colours often shift to be more saturated to compensate for the dark background.

Dark theme overrides
.dark {
  --color-background: oklch(0.14 0.01 260);
  --color-foreground: oklch(0.97 0.002 260);
  --color-card: oklch(0.18 0.008 260);
  --color-popover: oklch(0.17 0.008 260);
  --color-primary: oklch(0.68 0.16 265);
  --color-primary-foreground: oklch(0.98 0.002 260);
  --color-muted: oklch(0.20 0.006 260);
  --color-muted-foreground: oklch(0.72 0.005 260);
  --color-accent: oklch(0.52 0.11 245);
  --color-border: oklch(0.26 0.006 260);
}

Light / Dark Implementation

CSS custom properties approach

The most reliable theming approach uses :root for light tokens and a class-based override for dark tokens. This integrates with any framework and works with Tailwind v4's @theme inline directive.

@theme inline {
  --color-background: var(--color-background);
  --color-foreground: var(--color-foreground);
  --color-primary: var(--color-primary);
  /* Each semantic token maps to its custom property */
}

OKLCH colour space

Use the OKLCH colour space for all tokens. OKLCH provides perceptual uniformity — a 10-point lightness change looks equally different at any point on the scale — and gives independent control over lightness, chroma (saturation), and hue.

/* OKLCH gives you three independent levers */
--color-primary: oklch(0.42 0.17 265);
/*                  ↑       ↑    ↑            */
/*              lightness  chroma  hue (blue)  */

Benefits for theming:

  • Lightness swaps: Dark mode only adjusts lightness while preserving hue and chroma.
  • Chroma reduction: Lower chroma values prevent oversaturated colours on dark backgrounds.
  • Hue consistency: The same hue value across both themes ensures brand colour remains recognisable.

Avoid HSL for theming. HSL is not perceptually uniform — a 10% lightness change looks very different in blue vs. yellow, making cross-theme adjustments unpredictable. OKLCH is the modern standard and supported in all current browsers.

System Detection & Persistence

Detecting user preference

The browser exposes the user's system-level preference via prefers-color-scheme. Use it as the default, but always allow manual override.

Theme detection with next-themes
import { useTheme } from 'next-themes';

function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  return (
    <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
      {theme === 'dark' ? <SunIcon /> : <MoonIcon />}
    </button>
  );
}

The next-themes library handles:

  • Reading prefers-color-scheme on first visit
  • Persisting the user's choice to localStorage
  • Applying the .dark class to <html>
  • Preventing flash of wrong theme (via a blocking inline script)

Persistence strategy

SourcePriorityWhen used
localStorageHighestReturning user who previously toggled
prefers-color-schemeMediumNew visitor with a system preference
Light defaultLowestVisitor with no preference or preference blocked

Toggle Patterns

Three-state toggle

Offer three states: light, dark, and system (follow OS preference). This respects users who switch their system theme during the day.

function ThemeToggle() {
  const { theme, setTheme } = useTheme();
  const options = [
    { value: 'light', icon: SunIcon, label: 'Light' },
    { value: 'dark', icon: MoonIcon, label: 'Dark' },
    { value: 'system', icon: MonitorIcon, label: 'System' },
  ];

  return (
    <div className="flex gap-1 rounded-lg border p-1">
      {options.map(({ value, icon: Icon, label }) => (
        <button
          key={value}
          onClick={() => setTheme(value)}
          aria-label={label}
          data-active={theme === value}
          className="rounded-md p-2 data-[active=true]:bg-accent"
        >
          <Icon className="size-4" />
        </button>
      ))}
    </div>
  );
}

Transition between themes

Add a smooth transition to prevent jarring instant switches. CSS transition on the :root properties creates a crossfade effect.

:root {
  transition: background-color 0.3s ease, color 0.3s ease;
}

Dark Mode for Visual Assets

Images, icons, and illustrations designed against a light background may not work on dark surfaces.

Asset typeDark mode treatment
LogosProvide a separate inverted or filled version for dark backgrounds
IllustrationsUse SVG with currentColor or CSS custom properties for fills
Screenshots & photosApply a CSS brightness or opacity reduction: filter: brightness(0.8)
Icon spritesUse currentColor fill so icons inherit the text colour
Charts & graphsProvide a separate dark colour palette for chart series colours
/* Example: dim screenshots in dark mode */
.dark img[src*="screenshot"] {
  filter: brightness(0.8) contrast(1.1);
  border-color: var(--color-border);
}

Testing Strategy

CheckHow to test
Contrast passes WCAG AA in both themesAutomated check with axe-core or Lighthouse
All surfaces are reachableManual walkthrough of every route in both themes
Images have appropriate treatmentVisual check — no white-background logos floating on dark surfaces
Transition is smoothToggle rapidly between themes — no flicker or layout shift
prefers-color-scheme is respectedSet OS preference, visit fresh — correct theme loads
Manual override persistsToggle away from system preference, refresh — preference is saved
No flash of wrong themeHard reload or clear localStorage — inline script blocks flash

The most commonly missed dark-mode bug is a third-party embed or modal that ignores the theme class. Test widgets, maps, and iframes explicitly.

On this page