Dark Mode Implementation Done Right
Dark mode went from "nice to have" to "expected feature" in about three years. Users want it. Operating systems support it. If your app doesn't have it, people notice.
But implementing dark mode well is harder than it looks. I've seen teams slap filter: invert(1) on their CSS and call it done. The result is always terrible.
Let's talk about how to actually do this right.
Why Dark Mode Matters
It's not just aesthetic preference. Dark mode has real benefits:
Reduced eye strain in low-light environments. Staring at a bright white screen in a dark room is uncomfortable.
Battery savings on OLED screens. Black pixels are literally off, using zero power. On a phone with an OLED display, dark mode can extend battery life by 15-30%.
Accessibility for users with light sensitivity or certain visual conditions.
User preference is reason enough. Some people just prefer it. Respecting that preference is good UX.
The Wrong Way: Color Inversion
Inverting colors mathematically seems logical. White becomes black, black becomes white, everything in between flips accordingly.
Here's why it fails:
- Images get inverted too, looking like photo negatives
- Brand colors become their complements, breaking your identity
- Semantic colors reverse (your green success becomes magenta)
- Contrast ratios shift unpredictably
- It just looks bad, like a broken display
Don't do this. Ever.
The Right Way: Semantic Color Tokens
Instead of inverting, you define two complete color palettes and swap between them. This requires thinking about colors semantically rather than literally.
Instead of:
color: #FFFFFFbackground: #1A1A1A
Use semantic tokens:
color: var(--color-text-primary)background: var(--color-background)
Then define what those mean in each mode:
:root { --color-text-primary: #1A1A1A; --color-background: #FFFFFF; }
[data-theme="dark"] { --color-text-primary: #E0E0E0; --color-background: #121212; }
Your components never change. Only the token values do.
Designing Your Dark Palette
Dark mode isn't "light mode but dark." Colors behave differently on dark backgrounds.
Don't use pure black. #000000 is harsh and creates excessive contrast with white text. Material Design recommends #121212. Slightly lighter darks like #1A1A1A or #1E1E1E are easier on the eyes.
Don't use pure white for text. #FFFFFF on dark backgrounds is too bright. Use #E0E0E0 or similar. About 87% brightness is comfortable for primary text, 60% for secondary text.
Desaturate your accent colors. Saturated colors look more intense on dark backgrounds. That vibrant blue that works in light mode might feel aggressive in dark mode. Reduce saturation by 10-20%.
Use elevation with light, not shadow. In light mode, shadows create depth. In dark mode, shadows disappear into the darkness. Instead, use lighter surface colors for elevated elements. A modal might be #2D2D2D on a #121212 background.
Check your contrast ratios again. Just because something passed WCAG in light mode doesn't mean it passes in dark mode. Test everything again.
Handling Images and Media
Images don't automatically work in both modes. You have options:
Transparent PNGs often need different versions. A black logo on transparent looks fine on white, invisible on black. Provide light and dark variants.
Photos generally work in both modes, but consider adding a subtle overlay or reducing brightness in dark mode to reduce glare.
Illustrations might need separate versions if they use colors that clash with your dark palette.
The <picture> element with prefers-color-scheme media queries can swap images automatically:
<picture>
<source srcset="logo-dark.png" media="(prefers-color-scheme: dark)">
<img src="logo-light.png" alt="Logo">
</picture>
Detecting User Preference
Modern systems tell you what the user wants via prefers-color-scheme:
CSS: @media (prefers-color-scheme: dark) { /* dark styles */ }
JavaScript: window.matchMedia('(prefers-color-scheme: dark)').matches
But you should also let users override this in your app. Someone might want their OS in light mode but your app in dark mode, or vice versa.
Typical pattern:
- Default to system preference
- Allow manual override (light/dark/system)
- Store the preference (localStorage or user settings)
- Apply on page load before render to prevent flash
Preventing the Flash
Nothing says "amateur hour" like a white flash before dark mode kicks in. This happens when your page renders in light mode, then JavaScript switches to dark mode.
Solutions:
Inline script in head. Before any CSS loads, run a tiny script that checks preference and sets a class or attribute on the HTML element. No flash because styles load with the right mode already set.
Server-side rendering. Read the preference from a cookie and render the correct theme from the server. The HTML arrives ready to go.
CSS-only approach. Use prefers-color-scheme media query without JavaScript. Works for system preference but can't be overridden without JS.
For most apps, the inline script approach is simplest:
<script>
(function() {
const stored = localStorage.getItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const theme = stored || (prefersDark ? 'dark' : 'light');
document.documentElement.setAttribute('data-theme', theme);
})()
</script>
The Toggle UX
Give users a clear way to switch modes. Common patterns:
Sun/moon icon toggle. Simple, universally understood. Click to switch.
Three-way selector. Light / Dark / System. More explicit, lets users follow system while acknowledging the option exists.
In settings only. Keep the UI clean, put theme control in preferences. Works for apps where most users won't change it.
Wherever you put it, add a smooth transition when switching. Jarring instant switches feel broken.
* { transition: background-color 0.2s ease, color 0.2s ease; }
Keep the transition short - 200-300ms. Longer feels sluggish.
Testing Your Dark Mode
You need to actually use your dark mode, not just glance at it.
Checklist:
- Read a full page of content. Is it comfortable?
- Check every color state (hover, focus, disabled, error)
- View all images and illustrations
- Test in a dark room on a real device
- Test the mode toggle - no flash? Smooth transition?
- Run accessibility contrast checks
- Test on both OLED and LCD screens (blacks render differently)
Common Mistakes
Forgetting borders and dividers. Light mode borders might use light grays that disappear on dark backgrounds. Adjust them.
Inconsistent dark surfaces. If some cards are #1E1E1E and others are #2A2A2A for no reason, it looks sloppy. Be intentional about your elevation system.
Using transparency wrong. rgba(0, 0, 0, 0.1) for overlays works in light mode but is invisible in dark mode. Define semantic overlay colors for each mode.
Forgetting third-party embeds. That YouTube embed or tweet widget might not respect your theme. Not much you can do except be aware of it.
No system preference sync. If someone changes their OS theme while your app is open, it should respond. Listen for changes with matchMedia().addEventListener('change', ...).
Ship It
Dark mode is table stakes now. But doing it well still sets you apart. Invest the time to build a proper semantic color system, test thoroughly, and respect user preferences.
Your users will thank you, even if they don't know exactly what you did right. They'll just know it feels good.