EmpireUI
Get Pro
← Blog8 min read#dark mode#ui design#css

Dark Mode UI Design: Principles, Pitfalls and Best Practices

Dark mode isn't just a color swap — done wrong it destroys readability. Here's how to build dark UIs that actually work, with CSS, React, and accessibility covered.

Dark computer monitor with glowing interface and code on screen

Why Dark Mode Is Harder Than It Looks

Dark mode shipped as a system-level feature in macOS Mojave back in 2018. Ever since, every product team on the planet has had it on their roadmap — and a shocking number of them have shipped it badly. Not because it's technically hard. Because people treat it like a CSS variable swap and call it done.

It isn't. A light-to-dark conversion that just inverts hues produces muddy grays, invisible shadows, and text that feels like it's floating in soup. You're not repainting a wall — you're rethinking how light, surface, and contrast work together when the assumed ambient light source is gone.

Honestly, the biggest mistake is treating dark mode as a feature bolt-on rather than a design decision you make at the start of a project. If you're planning to support dark mode — and in 2026, you probably should — start thinking in color tokens, not hard-coded hex values.

The Color Science You Actually Need to Know

Your instinct will be to set your background to #000000. Don't. Pure black next to colored text creates what designers call "halation" — a blurring shimmer where the eye struggles to resolve the high-contrast edge. Google's Material Design settled on #121212 for a reason, and that 18px gap from true black matters more than it sounds.

Surfaces in dark UIs work on elevation. The higher a surface is (think modals, dropdowns, tooltips), the lighter it should be — not because of shadows, but because shadows don't read against dark backgrounds. You switch from depth-through-shadow to depth-through-lightness. Elevation 1 might be #1E1E1E, elevation 4 might be #2C2C2C. Increments of roughly 5-8 points of lightness per level.

Worth noting: pure-white text (#FFFFFF) on dark backgrounds causes the same halation problem in reverse. Drop to rgba(255,255,255,0.87) for body text and rgba(255,255,255,0.6) for secondary text — that's the Material Design spec, and it holds up well in practice.

If you want to see what expressive dark surfaces look like when they're pushed further, the glassmorphism components on Empire UI are a good reference — they layer translucency and blur on top of dark backgrounds in a way that rewards studying.

CSS Custom Properties: The Right Architecture

The correct approach is a token system defined at :root and flipped under a .dark class or [data-theme='dark'] attribute. Media queries alone (prefers-color-scheme) won't cut it once you want a manual toggle.

Here's a pattern that actually scales: ``css :root { --color-bg: #ffffff; --color-surface: #f4f4f5; --color-text-primary: #111111; --color-text-secondary: #555555; --color-border: rgba(0, 0, 0, 0.1); } [data-theme='dark'] { --color-bg: #121212; --color-surface: #1e1e1e; --color-text-primary: rgba(255, 255, 255, 0.87); --color-text-secondary: rgba(255, 255, 255, 0.6); --color-border: rgba(255, 255, 255, 0.08); } /* Still respect user OS preference when no manual choice is set */ @media (prefers-color-scheme: dark) { :root:not([data-theme='light']) { --color-bg: #121212; --color-surface: #1e1e1e; --color-text-primary: rgba(255, 255, 255, 0.87); --color-text-secondary: rgba(255, 255, 255, 0.6); --color-border: rgba(255, 255, 255, 0.08); } } ` You apply data-theme on <html> or <body>` and update it from JavaScript. This gives you both manual toggle and OS-default behavior without duplication of state logic.

One more thing — store the user's preference in localStorage. Nothing is more annoying than a dark mode that resets every page load.

React Implementation: A Theme Toggle That Works

A proper React theme hook is about 20 lines. Here's one that handles OS preference, persistence, and SSR without hydration mismatches: ``tsx import { useEffect, useState } from 'react'; type Theme = 'light' | 'dark'; export function useTheme() { const [theme, setTheme] = useState<Theme>(() => { if (typeof window === 'undefined') return 'light'; const stored = localStorage.getItem('theme') as Theme | null; if (stored) return stored; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); }, [theme]); const toggle = () => setTheme(t => (t === 'dark' ? 'light' : 'dark')); return { theme, toggle }; } ` Drop this in a shared context or call it at the root layout. The typeof window === 'undefined'` guard handles Next.js SSR — without it you'll get hydration warnings from mismatched server and client renders.

Quick aside: if you're using Tailwind, add darkMode: 'class' to your config and replace [data-theme='dark'] with .dark. The hook stays identical; you'd just toggle a class on <html> instead of the attribute. Either works.

Accessibility and Contrast — Where Most Dark Modes Fail

WCAG 2.1 AA requires a 4.5:1 contrast ratio for normal text, 3:1 for large text (18px+ regular or 14px+ bold). Run your dark mode through a contrast checker before you ship. Not after. Before.

The trap is branded accent colors. Your brand's purple that passed contrast in light mode almost certainly fails in dark mode — darker backgrounds raise the required luminance of foreground colors, and mid-range saturated hues often sit in a dead zone where they're readable on neither extreme. You may need a lighter variant of your accent specifically for dark surfaces.

In practice, semantic HTML matters even more in dark mode. Screen readers don't care about your color scheme, but users who rely on both high-contrast mode and dark mode simultaneously (yes, this population exists) will be hit hard by any element that communicates meaning purely through color. Always pair color changes with icons, labels, or pattern fills for state communication.

You should also test focus rings. The default browser focus outline — typically a blue glow — often disappears on dark backgrounds. Set an explicit outline color in your dark theme variables. A 2px solid rgba(255,255,255,0.7) ring works well as a baseline. You can get more creative with it, but don't skip it.

Common Pitfalls (and How to Dodge Them)

Images and media are the first surprise. A product screenshot or illustration designed for white backgrounds will look bizarre floating on #121212. You've got a few options: add a subtle dark card surface behind them, use images with transparent backgrounds and invert strategically, or — if you're feeling fancy — use CSS mix-blend-mode: luminosity to soften the contrast.

Shadows are the second one. box-shadow: 0 4px 24px rgba(0,0,0,0.3) is basically invisible on a dark background. Replace shadows with slight border highlights: border: 1px solid rgba(255,255,255,0.06) reads as depth in dark mode far better than any shadow value you'll find. You can also experiment with colored glows — a faint box-shadow: 0 0 20px rgba(120,80,255,0.2) reads beautifully on dark surfaces. The box shadow generator lets you preview these values in real time.

Third pitfall: third-party embeds. Stripe's payment iframe, a Google Map, an Intercom widget — none of them respond to your [data-theme] attribute. There's no silver bullet here, but wrapping them in a light-mode container via [data-theme='dark'] .third-party-wrap { color-scheme: light; background: white; } at least contains the damage.

Where to Go From Here

Dark mode is infrastructure, not decoration. Once you've got the token system in place, everything else — adding new components, expanding your palette, building out a design system — becomes genuinely easier because you're forced to think in abstractions rather than raw color values.

From here, you might want to push into more expressive dark UI territory. Neumorphism does interesting things in dark mode — the soft shadows and insets read very differently against dark surfaces than light ones, in ways that are worth exploring. Styles like cyberpunk or aurora are basically designed around the dark surface assumption and can show you how far you can push the aesthetics once the fundamentals are solid.

Look, dark mode done well is invisible — users just feel comfortable in your app. Done badly, it's a constant low-level irritant. The investment in a proper token architecture and contrast-checked palette pays off immediately in user perception, even if they can't articulate why. Start with the CSS variables, validate your contrast, test on OLED screens where true-black artifacts show up immediately, and you'll be in good shape.

FAQ

Does dark mode actually save battery life?

On OLED screens, yes — black pixels are literally off. On LCD displays, no meaningful difference. Don't design around battery savings; design around readability and user preference.

Should I use pure black (#000000) as my dark mode background?

No. Pure black creates halation effects against colored text and looks harsh on OLED. Start at #121212 or #0F0F0F and work up from there.

How do I handle images that were designed for light backgrounds?

Wrap them in a light-surface card, use transparent-background variants, or apply CSS mix-blend-mode. There's no single answer — it depends on the image type.

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

WCAG 2.1 AA requires 4.5:1 for body text and 3:1 for text 18px and larger. Test with a tool like WebAIM's contrast checker before shipping.

Free components in 40 styles
React & Tailwind, copy-paste ready.
Browse →

Read next

10 Neobrutalism UI Examples That Are Changing Web DesignGlassmorphism Form Design: Login, Signup and Contact FormsBuilding a Color System: Semantic Tokens, OKLCH and Dark ModeImplementing Dark Mode in React: CSS Variables, Tailwind, System Preference