When dark mode became a platform-level feature — first on macOS, then iOS, Android, Windows — designers everywhere celebrated. Then most of them implemented it incorrectly.
The most common mistake: inversion. Take the light UI, flip the colors, ship it. The background becomes near-black. The text becomes near-white. Everything is technically "dark." And everything feels slightly wrong, slightly harsh, slightly amateur.
Real dark mode design is about elevation through light, not the absence of it.
The Core Principle: Dark UIs Use Light to Show Depth
In physical space, we understand depth through light. Objects closer to a light source appear brighter. Objects in shadow recede.
Material Design (Google) codified this for digital dark interfaces with a principle called surface elevation: the higher an element sits in the z-axis — the more "on top" it is — the lighter its background should be.
This is counterintuitive but crucial. In a dark UI:
- The base layer (background) is the darkest
- Cards sit one level up, so they're slightly lighter
- Modals sit above cards, so they're lighter still
- Tooltips, dropdowns, floating elements are the lightest surfaces
You're not changing opacity or adding shadows (shadows barely read on dark backgrounds). You're tinting the surface with white at increasing opacity levels.
Building a Gray Scale That Works
The first thing most designers get wrong is reaching for #000000 or #111111 as their base. Pure black has no room to breathe. It creates extreme contrast that causes eye strain over long sessions.
Here's a functional dark scale built on a warm-gray foundation:
:root[data-theme="dark"] {
/* Surfaces — from deepest to highest elevation */
--surface-0: #121212; /* base background */
--surface-1: #1e1e1e; /* cards, sidebars */
--surface-2: #252525; /* dropdowns, popovers */
--surface-3: #2c2c2c; /* modals, overlays */
--surface-4: #333333; /* tooltips, top-layer UI */
/* Text */
--text-primary: rgba(255, 255, 255, 0.87);
--text-secondary: rgba(255, 255, 255, 0.6);
--text-disabled: rgba(255, 255, 255, 0.38);
/* Dividers and borders */
--divider: rgba(255, 255, 255, 0.12);
}Notice that text isn't #FFFFFF. Pure white on near-black creates a contrast ratio above 21:1 — technically accessible but visually exhausting. The rgba(255, 255, 255, 0.87) sweetspot lands around 15:1 contrast — still well above WCAG AA, but far more comfortable.
WCAG Contrast Ratios: The Non-Negotiable Numbers
The Web Content Accessibility Guidelines define minimum contrast requirements:
| Standard | Ratio | Use case |
|---|---|---|
| WCAG AA (normal text) | 4.5:1 | Body text, labels |
| WCAG AA (large text) | 3:1 | Headings ≥ 18px bold or ≥ 24px |
| WCAG AAA | 7:1 | Highest accessibility standard |
These aren't suggestions. They're the legal baseline in many jurisdictions and the ethical baseline everywhere else.
A simple rule: if you can't immediately see your text in a screenshot thumbnail, the contrast is too low.
Tools for checking:
- Colour Contrast Analyser (desktop app, free)
- Figma's built-in accessibility plugin
- Chrome DevTools → Accessibility pane (shows contrast ratios live)
The Elevation System in Practice
Let's see how this plays out in a real UI — a notification dropdown:
/* Base page */
body {
background-color: var(--surface-0); /* #121212 */
}
/* Content cards */
.card {
background-color: var(--surface-1); /* #1E1E1E — slightly elevated */
border: 1px solid var(--divider);
border-radius: 12px;
}
/* Dropdown menu */
.dropdown {
background-color: var(--surface-2); /* #252525 — higher elevation */
border: 1px solid var(--divider);
border-radius: 8px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); /* shadows still add depth */
}
/* Modal */
.modal {
background-color: var(--surface-3); /* #2C2C2C */
}The differences between these values are subtle — about 6-7 hex steps in lightness each time. That's intentional. You're not trying to make the difference obvious; you're trying to make it felt. Users shouldn't notice the elevation system consciously. They should just intuit that the modal is "on top of" the page.
Accent Colors in Dark Mode: Don't Just Reuse Your Light Palette
This is a common trap: you have a brand blue (#1971C2) that works beautifully in light mode. You switch to dark mode and keep it. It now looks harsh and slightly neon against the dark background.
Accent colors in dark mode typically need to be slightly desaturated and lightened. A color that carries weight against white needs to be modified to not overwhelm a dark background.
Compare:
/* Light mode accent */
--accent: #1971c2;
/* Dark mode accent — lighter, slightly less saturated */
--accent: #74c0fc;The dark mode accent is from the same blue family, but several steps lighter. It reads as active and intentional without screaming.
This applies to every brand color. Test each one on both backgrounds. They will almost never work identically on both.
Practical Checklist
Before shipping a dark mode interface, verify:
- Base background is
#121212–#1A1A1A, not#000000 - Each elevation layer is 5–8 lightness steps above the previous one
- Text contrast ratios: ≥ 4.5:1 for body, ≥ 3:1 for large text
- No pure
#FFFFFFtext — usergba(255,255,255,0.87)or equivalent - Accent colors are lightened and tested against dark surfaces
- Shadows are darker and more opaque (they don't read well at low opacity on dark)
- Borders use
rgba(255,255,255,0.12)rather than dark-on-dark attempts - You've tested in dim lighting conditions, not just a bright studio monitor
Dark mode done right isn't dark at all — it's a carefully controlled family of near-blacks, each carrying meaning about depth and hierarchy.
The difference between professional dark UI and amateur dark UI is not the darkness. It's the light.