Dark mode website design

Dark mode website design is a UI pattern where background surfaces use dark gray tones (not pure black) and foreground elements use lighter text and accent colors, with contrast ratios that meet WCAG 2.1 AA standards. Designers implement it via the prefers-color-scheme CSS media query, often paired with a manual toggle that persists the user's choice in localStorage.

TL;DR

Dark mode done right means #121212 backgrounds (not #000000), surface elevation expressed through lighter shades of gray, a minimum 4.5:1 contrast ratio for body text, prefers-color-scheme: dark detection plus a manual toggle that stores preference in localStorage, and images with adjusted brightness or transparent-background handling. Skip any of those and you end up with an inaccessible, ugly dark theme.


Why isn't pure black the right choice for dark mode?

Pure black (#000000) creates two concrete problems: OLED black smearing and halation.

Black smearing happens because OLED pixels physically turn off to display true black. When bright content scrolls across a #000000 background, those pixels take longer to turn back on -- you see visible trails. This isn't a software bug. It's how the hardware works.

Halation is worse. At 21:1 contrast (white text on black), bright text bleeds into the surrounding black area for anyone with astigmatism. That's 33% of the population, per the American Academy of Ophthalmology. I've watched users with mild astigmatism squint at pure-black dark modes and ask if something's wrong with their eyes. Nothing is wrong -- the design is.

Google Material Design 3 specifies #121212. Apple Human Interface Guidelines use #1C1C1E for system backgrounds. Both sit dark enough to feel like dark mode, light enough to avoid smearing and halation.

Here's what we ship:

:root {
  --color-bg-primary: #ffffff;
  --color-bg-surface: #f5f5f5;
  --color-text-primary: #1a1a1a;
  --color-text-secondary: #555555;
}

[data-theme="dark"] {
  --color-bg-primary: #121212;
  --color-bg-surface: #1e1e1e;
  --color-text-primary: #e0e0e0;
  --color-text-secondary: #a0a0a0;
}

Notice #e0e0e0 for primary text instead of #ffffff. That drops the contrast ratio to roughly 13.5:1 -- still well above the 4.5:1 WCAG AA minimum, but comfortable enough to read for 20 minutes without your eyes watering.

GitHub uses dark grays instead of pure black for the same reason. When I audit client sites, the first thing I check is whether they've fallen into the #000000 trap.

How does surface elevation work in dark themes?

In dark mode, elevated surfaces get lighter, not darker. A modal sitting above the page should be a lighter shade of gray than the background. This inverts light mode, where you use box-shadow to communicate elevation.

Material Design 3 defines elevation through semi-transparent white overlays on the base surface:

Elevation Level dp Overlay Opacity Hex (over #121212)
0 (base) 0 0% #121212
1 1 5% #1e1e1e
2 3 7% #222222
3 6 8% #252525
4 8 9% #272727
5 12 11% #2c2c2c
6 24 16% #353535

Express this in CSS with color-mix():

[data-theme="dark"] {
  --elevation-1: color-mix(in srgb, #ffffff 5%, #121212);
  --elevation-2: color-mix(in srgb, #ffffff 7%, #121212);
  --elevation-3: color-mix(in srgb, #ffffff 8%, #121212);
  --elevation-4: color-mix(in srgb, #ffffff 9%, #121212);
}

.card {
  background-color: var(--elevation-2);
}

.modal {
  background-color: var(--elevation-4);
}

color-mix() shipped in Chrome 111, Firefox 113, and Safari 16.2 (March 2023). For older browsers, hardcode the hex values from the table above.

Shadows still matter

You don't eliminate shadows in dark mode -- you adjust them. A light mode shadow of 0 4px 12px rgba(0, 0, 0, 0.15) becomes 0 4px 12px rgba(0, 0, 0, 0.4) in dark mode. The shadow needs higher opacity to show against an already-dark background, but the surface color is the primary elevation cue.

.card {
  box-shadow: 0 4px 12px rgba(0, 0, 0, var(--shadow-opacity));
}

:root {
  --shadow-opacity: 0.15;
}

[data-theme="dark"] {
  --shadow-opacity: 0.4;
}

What contrast ratios pass WCAG in dark mode?

Same ratios as light mode: 4.5:1 for normal text (under 18pt or 14pt bold) and 3:1 for large text (18pt+ or 14pt+ bold) at WCAG AA. Level AAA raises those to 7:1 and 4.5:1.

The trap: designers pick accent colors that work on white and port them directly. #1E90FF (Dodger Blue) gives you 4.6:1 against white -- barely passing AA. Against #121212? 4.4:1. Fails.

You need separate accent palettes:

:root {
  --color-accent: #0066cc; /* 4.8:1 against #ffffff */
}

[data-theme="dark"] {
  --color-accent: #5ca0e8; /* 5.2:1 against #121212 */
}

Tools for checking contrast

  • WebAIM Contrast Checker (webaim.org/resources/contrastchecker): Enter hex values, get instant ratio.
  • Firefox DevTools Accessibility Inspector: Highlights contrast failures in the rendered page.
  • axe DevTools (browser extension): Audits the entire page. Free tier covers contrast.
  • Stark (Figma plugin, $12/month): Checks contrast during design, before you write code.

Run contrast checks against both themes before shipping. I've seen too many launches where light mode passed every audit but dark mode was never tested.

Non-text contrast

WCAG 2.1 SC 1.4.11 requires 3:1 contrast for UI components and graphical objects. Form field borders, icon strokes, focus indicators -- all need to clear 3:1 against the dark background. A #555555 border against #121212 gives you only 2.3:1. Bump to #757575 for 3.8:1.

How do you detect system dark mode with prefers-color-scheme?

prefers-color-scheme detects whether the user's OS is set to light or dark mode. Browser support landed in Chrome 76 (July 2019), Firefox 67 (May 2019), and Safari 12.1 (March 2019).

CSS-only approach:

@media (prefers-color-scheme: dark) {
  :root {
    --color-bg-primary: #121212;
    --color-bg-surface: #1e1e1e;
    --color-text-primary: #e0e0e0;
  }
}

This works out of the box. No JavaScript. No toggle. The site matches the OS setting automatically.

Problem: the user can't override it. If someone runs their OS in dark mode but wants your site in light mode, they're stuck.

How do you build a manual toggle that persists?

The answer: detect system preference, allow manual override, persist that override in localStorage.

Step 1: Set up CSS custom properties

:root {
  --color-bg: #ffffff;
  --color-text: #1a1a1a;
}

[data-theme="dark"] {
  --color-bg: #121212;
  --color-text: #e0e0e0;
}

Step 2: Inline script in `` to prevent flash

If you put theme detection in an external JS file, users see a flash of the wrong theme. This inline script in <head> blocks rendering for under 1ms and prevents the flash:

<script>
  (function() {
    var stored = localStorage.getItem('theme');
    if (stored) {
      document.documentElement.setAttribute('data-theme', stored);
    } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
      document.documentElement.setAttribute('data-theme', 'dark');
    }
  })();
</script>

Step 3: Toggle button with persistence

<button id="theme-toggle" aria-label="Toggle dark mode">
  <span class="icon-sun" aria-hidden="true">☀️</span>
  <span class="icon-moon" aria-hidden="true">🌙</span>
</button>
const toggle = document.getElementById('theme-toggle');

toggle.addEventListener('click', () => {
  const current = document.documentElement.getAttribute('data-theme');
  const next = current === 'dark' ? 'light' : 'dark';
  document.documentElement.setAttribute('data-theme', next);
  localStorage.setItem('theme', next);
});

// Listen for OS-level changes while user is on site
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
  if (!localStorage.getItem('theme')) {
    document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light');
  }
});

Key detail: the change event listener only fires if the user hasn't manually chosen a theme. Once they click the toggle, their explicit choice overrides the OS setting.

Step 4: Respect `prefers-reduced-motion` for transitions

If you animate the theme switch, check for reduced motion:

@media (prefers-reduced-motion: no-preference) {
  :root {
    transition: background-color 0.3s ease, color 0.3s ease;
  }
}

Toggle placement

Put the toggle somewhere consistent and discoverable -- header nav, near the logo, or in a settings menu. GitHub puts theirs in the user settings dropdown. Apple auto-detects without offering a manual toggle. I think the hybrid is best: auto-detect, but give the user a visible override.

Use a <button> element, not a checkbox or <div> with a click handler. <button> gets keyboard focus for free and announces correctly to screen readers when you set aria-label.

How should images and shadows behave in dark mode?

Images are the most overlooked part of dark mode implementation. Photographs, illustrations, and icons each need different treatment.

Photographs

Full-color photos look fine in dark mode but can feel blindingly bright against a dark background. Reduce brightness slightly:

[data-theme="dark"] img:not(.no-dim) {
  filter: brightness(0.85) contrast(1.05);
}

[data-theme="dark"] img:not(.no-dim):hover {
  filter: brightness(1) contrast(1);
}

The :hover restore lets users see the original on interaction. Add the .no-dim class for hero images or product shots where color accuracy matters.

SVGs with transparent backgrounds

An SVG logo with black strokes and a transparent background vanishes against #121212. Three options:

  1. Provide two versions: Light-on-dark and dark-on-light, swapped via CSS.
  2. Use currentColor: If the SVG uses currentColor for fills/strokes, it inherits from the CSS color property and adapts automatically.
  3. CSS filter inversion: filter: invert(1) works in a pinch but mangles multi-color SVGs.

Option 2 for icons:

<svg viewBox="0 0 24 24" fill="currentColor">
  <path d="M12 2L2 7l10 5 10-5-10-5z"/>
</svg>

Option 1 for logos with brand colors:

<picture>
  <source srcset="/logo-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="/logo-light.svg" alt="Company Logo">
</picture>

Note: <picture> only works for auto-detection. For a manual data-theme toggle, use JavaScript or CSS display: none swapping.

PNGs with transparent backgrounds

Same problem as SVGs. Screenshots with white UI elements vanish against light backgrounds. Fix: add a subtle background to the image container or provide alternate assets.

[data-theme="dark"] .screenshot-container {
  background: var(--elevation-2);
  border-radius: 8px;
  padding: 8px;
}

Shadows revisited

Colored shadows (e.g., box-shadow: 0 4px 20px rgba(59, 130, 246, 0.3) for a blue glow) look stunning in dark mode but garish in light mode. Dark backgrounds make colored shadows pop. This is one area where dark mode gives you more creative room.

Performance and accessibility caveats

Dark mode isn't free. Real costs:

Performance

  • CSS custom properties add repaint cost. When you toggle data-theme, the browser recalculates styles for every element referencing those variables. On a page with 2,000+ DOM nodes, I've measured repaints taking 8-15ms on mid-range Android devices (Moto G Power). That's within a 60fps frame budget (16.67ms), but it's not zero.
  • Inline <head> scripts block rendering. The flash-prevention script adds ~0.5KB to the critical rendering path. Worthwhile tradeoff, but be aware.
  • Two sets of image assets double image weight. Use <picture> with media queries so the browser only downloads the relevant variant.

Accessibility

  • Color is not the only indicator. If you use color to indicate state (errors, success, active tabs), make sure those indicators work in both themes.
  • Focus indicators must be visible in both themes. The default browser focus ring can vanish against dark blue surfaces. Define explicit focus styles for both themes.
  • Test with screen readers. NVDA (free on Windows) and VoiceOver (built into macOS/iOS) should announce your toggle button correctly. aria-label="Switch to dark mode" is better than aria-label="Toggle theme" because it tells the user what will happen.
  • prefers-contrast: more is a separate media query (Chrome 96, Firefox 101) that some users enable alongside dark mode. If someone has both, your dark theme needs even higher contrast ratios. Test for this combination.

WCAG 2.1 doesn't require dark mode. But if you offer it, both modes must meet the same accessibility criteria. Shipping a light mode that passes AA and a dark mode that doesn't is a liability. The EU's European Accessibility Act (effective June 2025) and the ADA both reference WCAG.

Real-world examples worth studying

Site Base dark BG Text color Toggle type Notable detail
GitHub #0d1117 #e6edf3 Settings dropdown Uses dark grays, not pure black
MDN #1b1b1b #e0e0e0 Header toggle Persists in localStorage
Stripe #0a2540 #adbdcc Auto-detect only Dark navy, not gray -- brand-driven
Linear.app #101010 #d4d4d4 Settings + auto Near-black, not pure; heavy glow shadows
Tailwind CSS #0f172a #e2e8f0 Header toggle Slate palette, stores in localStorage

Stripe's approach stands out: their dark background is deep navy (#0a2540), not neutral gray. This reinforces brand identity. If your brand has a strong color, consider a tinted dark surface.

When does dark mode make sense for your site?

Not every site benefits equally. Content-heavy reading sites (blogs, documentation, news) see the highest user preference -- developers in particular. GitHub's annual survey consistently shows 70%+ of developers prefer dark mode.

E-commerce sites with heavy product photography need caution. Dimming product images reduces their impact. If you're selling jewelry, makeup, or anything where color accuracy matters, test dark mode with real product shots before committing.

FAQ

Should I use #000000 for dark mode backgrounds? No. Pure black causes halation for users with astigmatism and creates black smearing on OLED displays. Use #121212 or similar.

Does dark mode save battery?

On OLED and AMOLED screens, yes. Google confirmed in 2018 that dark mode on YouTube reduced screen power by 60% at full brightness. On LCD screens, savings are negligible because the backlight stays on.

Do I need JavaScript for dark mode?

Not for basic system-preference detection -- prefers-color-scheme: dark in CSS handles that. But for a manual toggle that persists across page loads, you need JavaScript to read/write localStorage.

What's the minimum contrast ratio for dark mode text?

Same as light mode: 4.5:1 for normal text (WCAG AA), 3:1 for large text. No separate dark mode standard. Test both themes with the same rigor.

How do I prevent a flash of wrong theme on page load?

Place a small inline <script> in <head> that reads localStorage and sets data-theme before the browser paints. Blocks rendering for under 1ms but prevents the flash entirely.

Should I force dark mode on all users?

No. Always respect user choice. Detect system preference, offer manual override, and persist it. Forcing a mode violates the principle behind prefers-color-scheme.