Dark mode design

Status: 🟩 COMPLETE Last updated: 2026-06-19 Plain-English tagline: The design half of dark mode — choosing the right shade of “dark” (not pure black), de-saturating accent colors, rebalancing contrast, handling images and shadows — without just inverting every color and calling it done.


In plain English

“Dark mode” sounds simple: swap white backgrounds for black, swap black text for white, done. In practice, that naive inversion produces a UI that hurts to look at — heavy text bleeds, saturated colors burn, shadows vanish, photos jump out like they’re floating.

Dark mode design is the practice of building a coherent SECOND palette that’s optimized for low-light viewing, not a mechanical inversion of the light palette. It’s a parallel design that:

  • Uses a dark gray background (not pure black)
  • De-saturates and slightly brightens accent colors
  • Lowers text intensity (off-white, not pure white)
  • Re-tunes contrast to feel comfortable, not punishing
  • Adjusts borders, shadows, photos to work on dark surfaces
  • Respects user preference via prefers-color-scheme

This entry is the DESIGN view of those decisions. For the technical CSS implementation (toggle class, CSS variables, persistence), see the dark mode technical entry and the set-up-dark-mode how-to.

In 2026, dark mode is no longer optional. iOS, Android, macOS, Windows, every major desktop OS, and most modern websites support it. Users who set their system to dark mode expect every webapp to follow. Done well, it’s a feature users love. Done badly, it’s worse than no dark mode at all — broken text, weird color shifts, unreadable forms.


Why it matters

Three reasons dark mode is worth the design effort:

  1. Real user preference, at scale. Around 50-65% of users (per various 2023-2025 surveys) set their devices to dark mode either always or on a schedule. Skipping dark mode means half your users see your app in a mode that doesn’t match their other apps.

  2. Reduces eye strain in low light. Reading a bright white page in a dim room is uncomfortable. Dark mode is a real ergonomic win for evening / morning / late-night use.

  3. OLED battery savings. On phones with OLED displays (iPhone X+, most modern Android), dark pixels are literally OFF — dark mode saves ~30-60% power on those screens. For a daily-use app, this matters.

The trade-off: dark mode design is a parallel design system. Every color, every shadow, every image needs to be considered in BOTH modes. Doubles the design surface; significantly increases the bug surface (it’s easy to forget to test one mode).

For Bible Quest-style projects, dark mode is part of the playbook’s “dark-mode-from-day-one” rule. Build it in alongside light mode, not as a retrofit. Retrofitting is much harder.


The core principles

1. Don’t use pure black (#000) as the background

Pure black + pure white text creates “halation” — light text appears to vibrate at the edges. It’s high-contrast in the technical sense but fatiguing to read.

Good dark backgrounds:

  • #0c0c0c to #1a1a1a (near-black, very dark gray)
  • oklch(0.18 0.005 240) — slightly cool dark gray
  • oklch(0.14 0.01 30) — slightly warm dark gray (warmer feels more inviting)

The shadcn default dark theme uses --background: 222.2 84% 4.9% — a very dark blue-gray, not pure black.

2. Don’t use pure white text

Pure #ffffff on a dark background bleeds and feels piercing. Soften to an off-white:

  • Body text: oklch(0.95 0 0) to oklch(0.92 0 0) (near-white, slightly muted)
  • Headings: slightly brighter than body
  • Muted text: oklch(0.65 0 0) to oklch(0.55 0 0) (still readable, clearly de-emphasized)

The shadcn default dark theme: --foreground: 210 40% 98% — bright but not pure.

3. De-saturate (and slightly brighten) accent colors

A vivid #3b82f6 blue looks crisp on white; on a dark background it BURNS. Optical reasons: saturated colors look more saturated against dark surrounds.

The fix: pull saturation back by ~10-20%, and bump lightness slightly, for the dark mode variant.

Example for a primary blue:

ModeHSL
Lighthsl(220, 90%, 50%) — vivid blue
Darkhsl(220, 75%, 60%) — slightly less saturated, slightly brighter

The visual weight feels similar across modes; the eye strain is lower.

4. Adjust shadows to be subtle (or remove them)

Drop shadows on dark surfaces are nearly invisible — the shadow is dark, the surface is dark. Three options:

  • Remove them entirely in dark mode. Use a subtle border instead.
  • Use a lighter shadow (the surface above casts a brighter highlight) — counterintuitive but works.
  • Use a darker glow instead of a shadow (a brighter ring around an active element).

Don’t try to use the same shadow values for both modes.

5. Tune borders

A border-gray-200 (light gray) border on a white background is subtle and tasteful. The same border-gray-200 on a dark background is INVISIBLE.

Use semantic tokens: border-border (which resolves to a different value in dark mode). Or define explicit dark-mode borders:

:root {
  --border: 220 13% 91%;        /* light mode: very light gray */
}
.dark {
  --border: 215 28% 22%;        /* dark mode: dark gray, but lighter than background */
}

6. Re-evaluate images and photos

Photos pop hard against dark backgrounds. A bright sunny landscape photo on a dark page can feel jarring — it draws the eye disproportionately.

Three patterns:

  • Lower the brightness slightly of images in dark mode using filter: brightness(0.9) (subtle)
  • Add a subtle border or rounded mask to soften the edge between photo and dark surrounding
  • Replace with darker variants when a photo is purely decorative

For functional images (product photos, user avatars), leave them. For background imagery, dimming usually helps.

7. Don’t invert semantic colors blindly

A red error color in light mode should usually be a different red in dark mode — slightly less saturated, slightly lighter, to maintain meaning without burning. Same for green success, yellow warning, blue info.

:root {
  --destructive: hsl(0 84% 60%);
}
.dark {
  --destructive: hsl(0 75% 65%);
}

A common mistake: leaving --destructive the same value in both themes. The same red looks aggressive in dark mode and washed out in light mode.

8. Test BOTH modes for every screen

A change in light mode may look right but break dark mode. A new component may work in both initially, then drift. The discipline: every PR that touches UI should be verified in both modes. Browser dev tools’ “Emulate CSS prefers-color-scheme” toggle makes this fast.


A concrete example: the same component in both modes

For a Bible Quest card:

<article className="
  rounded-lg border bg-card p-4
  text-card-foreground
  hover:bg-accent hover:text-accent-foreground
  focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2
  focus-within:ring-offset-background
">
  <h3 className="text-lg font-semibold">{lesson.title}</h3>
  <p className="text-sm text-muted-foreground">{lesson.description}</p>
  <Button>Continue</Button>
</article>

In light mode:

  • bg-card = near-white card surface
  • text-card-foreground = near-black text
  • border = light gray
  • text-muted-foreground = medium gray
  • hover:bg-accent = slightly tinted hover surface
  • Button: brand color primary, white text

In dark mode (same code, semantic tokens swap values):

  • bg-card = slightly elevated dark gray (lighter than page background)
  • text-card-foreground = off-white
  • border = mid-dark gray (just visible)
  • text-muted-foreground = mid gray (clearly secondary but readable)
  • hover:bg-accent = subtly lighter dark surface
  • Button: same brand color (slightly de-saturated), darker text inside

The COMPONENT didn’t change. The TOKENS did. This is why semantic tokens (shadcn’s pattern) win — components don’t need to know about themes.


The “elevation” trick

A subtle dark mode pattern: lighter surfaces feel “closer.”

In light mode, drop shadows imply depth. In dark mode, you can’t really use shadows. Instead, lift surfaces by making them lighter (closer to white) and ground them by making them darker (closer to black).

Material Design 3 calls this “elevation tinting.” A modal at “elevation 8” is lighter than a card at “elevation 4,” which is lighter than the page background at “elevation 0.”

.dark {
  --background: oklch(0.16 0.005 240);     /* page */
  --card: oklch(0.20 0.005 240);            /* slightly lighter */
  --popover: oklch(0.24 0.005 240);         /* even lighter */
  --dialog: oklch(0.28 0.005 240);          /* lighter still */
}

The user sees layered surfaces without any shadows. It’s a quiet, professional effect.


Respecting prefers-color-scheme

Users set their OS preference. Your app should respect it by default:

// In a component using next-themes (the canonical Next.js pattern)
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
  {children}
</ThemeProvider>

This means:

  • A user with macOS in dark mode → your app loads in dark mode
  • A user with iOS schedule (dark at 8pm) → your app follows
  • A user who manually toggles → your choice persists, overriding the OS until they reset

The “system” default + explicit toggle is the modern standard. Don’t force a default; let users decide.

A <ThemeToggle /> button somewhere in the UI gives explicit control. shadcn ships a polished one.


The flash-of-wrong-theme (FOWT) problem

A subtle bug: a user with a dark OS preference loads your site. For 50ms, the page is white (light mode default). Then JS hydrates and switches to dark mode. The flash is jarring.

Fixes:

  • Inline a script in <head> that reads the user preference from localStorage and sets the class="dark" BEFORE the React tree mounts
  • Modern frameworks (next-themes) ship this script
  • For Next.js, use the App Router’s suppressHydrationWarning on <html> to avoid hydration mismatch warnings

This is why “just CSS dark mode” doesn’t usually work — JavaScript is required to load the user’s persisted preference without the flash.


What “dark mode” doesn’t mean

A few common mistakes:

  • Not literally inverting the palette. A “Just filter: invert(1) on the body” hack technically works for content but inverts photos and breaks colors. Build a parallel palette.
  • Not just a “Solarized” / “Monokai” code editor theme. Webapp dark mode is its own design language, not a syntax-highlighting choice.
  • Not “the same UI but dimmer.” It’s a deliberate parallel design.
  • Not “true black” (AMOLED) by default. True black saves the most battery on OLED but feels unfriendly. Most apps use a slightly-lifted dark gray. Optional “AMOLED black” mode for power users.
  • Not a setting buried in advanced preferences. Should be discoverable — usually a sun/moon toggle in the header or settings page.

Common gotchas

  • Pure black backgrounds make text vibrate (halation). Use a very dark gray (e.g., #0c0c0c) instead. Read it for hours; you’ll see the difference.

  • Pure white text on dark looks aggressive. Soften slightly (e.g., #f5f5f7 rather than #ffffff).

  • Saturated brand colors look more saturated in dark mode. A blue button that’s tasteful on white can be glaring on dark. De-saturate by 10-20% for the dark variant.

  • Borders disappear on dark surfaces if you don’t adjust them. A border-gray-200 invisible on bg-black. Use semantic tokens; pre-define a dark border.

  • Shadows don’t work in dark mode. Replace with subtle borders or elevation tinting.

  • Photos pop too hard. Dim photos in dark mode (filter: brightness(0.85) is a common trick) — or apply a subtle border / soft mask.

  • Charts and data viz need separate dark palettes. Colors that work for bars / lines on white don’t work on dark. Recharts, D3, ECharts all support theme-aware palettes.

  • SVG icons can break. An icon hardcoded to fill="#000" is invisible in dark mode. Use fill="currentColor" and let CSS set the color.

  • Code blocks and syntax highlighting need their own themes. Light-mode syntax themes (e.g., default GitHub) are unreadable on dark backgrounds. Use a paired dark theme (e.g., shiki’s github-dark + github-light).

  • PDFs, embedded iframes, and third-party widgets stay in their own mode. Your dark page may host a white widget that breaks the visual coherence. Either restyle (if allowed) or design around it.

  • Form input defaults are usually wrong. Browser default inputs are white-with-black-text — they don’t auto-darken. Style explicitly via CSS variables for both modes.

  • autofill styling overrides your dark mode. Browser-autofilled inputs (yellow background) bypass your CSS. Use the -webkit-autofill pseudo-class to restyle.

  • Selected text color may break in dark mode. Default selection blue is invisible on dark blue surfaces. Set ::selection explicitly.

  • Pop-up <select> menus inherit the browser, not your styles. A native <select> opens with the OS native menu — works fine in dark mode, but the contents are styled by the OS. Replace with a custom component (Radix Select, shadcn Select) if needed.

  • Inverse colors are not symmetric. “Black on white” should NOT become “white on black” — the relationships shift. Tune for dark mode independently.

  • Hover states are harder to see in dark mode. A subtle background shift on hover can be invisible on a dark surface. Make hover changes slightly more prominent in dark mode.

  • Focus rings on dark backgrounds need re-tuning. A ring-blue-500 ring that’s vivid on white may be too vivid on dark. Tone down.

  • The “flash of wrong theme” (FOWT) is real and ugly. Use a synchronous theme-detection script BEFORE the React tree mounts. next-themes handles this.

  • Persisting the choice across page loads matters. localStorage is the standard. Sync with the system preference via the system mode.

  • Dark mode toggle UX choices matter. Sun/moon icons are universally recognized. Avoid text-only toggles (“Theme: Dark”) in the header — too verbose.

  • Test on real devices, not just dev tools. The “Emulate prefers-color-scheme” toggle works for development but doesn’t show real OLED behavior. Test on phones.

  • Don’t ship a dark mode that’s only 80% done. A page that’s mostly dark but has one white modal feels broken. Either complete it or hide the toggle until ready.

  • A11y rules don’t change in dark mode. Contrast minimums apply equally. Verify both palettes against WCAG AA.

  • Animation perception differs in dark mode. Bright animated content on dark feels more attention-grabbing. Use motion sparingly in dark mode.

  • High-contrast OS mode is separate from dark mode. Some users (Windows specifically) have a “high contrast” accessibility mode that overrides BOTH light and dark with specific accessibility-driven colors. Don’t fight it; let the OS take over for those users.

  • color-scheme CSS property tells the browser about your mode. Setting color-scheme: dark makes native scrollbars, form controls, autofill flip to dark variants automatically. Easy win.

  • Don’t auto-switch based on time of day. Sound smart; usually unwanted. Users with prefers-color-scheme: light at 11pm WANT light. Don’t override.

  • Email clients usually invert your emails imperfectly. When sending HTML emails, design for inversion gracefully. Mark images that shouldn’t be auto-inverted.

  • Bible Quest, like every modern project, ships with dark mode from day one — see the playbook. Retrofitting is painful; designing in parallel is normal pace.

  • AI-generated dark-mode CSS often misses the principles. Asking Claude to “add dark mode” yields literal inversion — color tokens get flipped, but de-saturation, photo dimming, semantic-color re-tuning don’t happen. Apply this entry’s principles after the mechanical pass.


See also


Sources