Dark mode is a perceptual rebuild, not an inverted stylesheet. Master elevation, contrast, desaturated accents, no flash theming, and a dark room QA pass.
A client once told me dark mode was "just a toggle, should take an afternoon." So I gave him the afternoon version, on purpose, to make a point. I flipped the background to black, pushed the text to white, left everything else exactly where it was, and screen-shared the result. It looked like a ransom note. The body copy buzzed against the pure-black canvas, the brand blue turned into a neon smear, every drop shadow vanished, and the hero image sat there glowing like someone left a lightbox on in a dark room. He stopped asking for the afternoon version.
That afternoon version is what most people picture when they say "dark mode": a color flip. And a color flip is not dark mode. It is the same layout wearing a costume that happens to fight human vision. A real dark theme is closer to a physics problem than a styling problem. You are simulating how a dim surface catches light, how the eye adapts when the average brightness of the screen collapses, and how depth reads once you can no longer lean on shadows. Get the physics wrong and it feels cheap, no matter how good your light theme was.
I have shipped dark themes into Flutter apps, marketing sites, and fintech dashboards, and the failures rhyme every time. They all trace back to treating a dark theme as background: black; color: white; and calling the ticket done. What follows is the model I actually build against — the perceptual reasoning first, then the concrete tokens, functions, and QA pass that make a dark mode read as designed rather than inverted.
Inversion assumes the two themes are mirror images. They are not, because your eye is not a mirror.
In a light theme the page is bright and your accents are darker islands sitting on top of it. Contrast does gentle work — dark marks on light paper is how print has run for centuries, and your visual system is tuned for exactly that. Flip it and you get a mostly-dark field with bright islands punching holes in it. Bright-on-dark is a genuinely harder problem, because of how the eye handles high-luminance edges once it has adapted to the dark.
Two things break the instant you flip:
#FFFFFF) on pure black (#000000) is the maximum possible luminance jump, and for a lot of people — anyone with astigmatism especially — the letters visibly smear and vibrate. It is physically tiring to read.There is a subtler failure too: contrast ratios do not invert cleanly. WCAG contrast is a ratio of relative luminances, and that ratio is asymmetric around the midpoint. A text/background pair that comfortably clears 4.5:1 in light mode does not automatically clear it when you swap the two luminances — you have to re-measure every pair, not assume the mirror holds. So the job is not "swap two variables." The job is to rebuild your depth and contrast model for a medium where light behaves differently.
This is the one idea that separates a real dark theme from a costume, so I will state it flatly: in the dark, elevation is expressed by making surfaces lighter, not by making shadows darker.
Think about how the physical world behaves. A raised object catches more ambient light on its top face. In a dark room, the thing nearest a light source is the brightest thing you see. Material Design formalized this and the instinct is correct: a card floating above the background is a lighter grey than the background, a dialog above the card is lighter still. Higher elevation, lighter surface.
Contrast that with the light theme, where elevation is a shadow — a darker smudge underneath the raised element. That model does not transfer. Keep leaning on shadows in the dark and you get nothing back, because a dark shadow on a dark surface has no contrast to show.
Here is the surface ramp I actually use, written as tokens rather than raw hex so the intent stays legible:
// Dark theme elevation ramp — higher = lighter, never pure blackconst surface = { 'background': Color(0xFF121212), // base canvas, dark-adapted grey (not #000) 'surface1': Color(0xFF1E1E1E), // cards, list tiles 'surface2': Color(0xFF242424), // raised cards, menus 'surface3': Color(0xFF2C2C2C), // dialogs, bottom sheets 'surface4': Color(0xFF333333), // top app bar when scrolled, snackbars};The deltas between steps are small — a handful of luminance points — but they are enough. Your eye reads "this is closer to me" without a single shadow drawn. Nest a menu inside a dialog inside a sheet, step each layer one shade lighter, and the hierarchy stays legible in a way shadows never manage in the dark.
You do not have to hand-pick every surface. A cleaner mental model: keep one dark base, then stamp a semi-transparent white overlay on top whose opacity scales with elevation. 0dp gets 0% white, a low card gets ~5%, a dialog ~11%, a menu ~12%. The higher it floats, the more light it catches. One base color, one overlay function, and your whole elevation system stays consistent by construction — instead of fifteen copy-pasted hex codes quietly drifting apart six months later.
In Flutter this is exactly what ThemeData.applyElevationOverlayColor does when useMaterial3 is on: Material 3 bakes the elevation tint into ColorScheme.surfaceContainer* roles so a Card at higher elevation composites a lighter surface for you. If you are hand-rolling it, the function is trivial — blend a white overlay over the base by an opacity keyed to elevation:
Color surfaceAt(double elevationDp) { // Opacity curve roughly matching Material's dark elevation overlay. final opacity = (4.5 * math.log(elevationDp + 1) + 2) / 100; return Color.alphaBlend( Colors.white.withOpacity(opacity.clamp(0, 0.16)), const Color(0xFF121212), );}The point is not the exact curve. The point is that elevation becomes a single derived value, so the ramp cannot drift.
Ban #000000 and #FFFFFF from your dark theme. Both of them.
Pure black is a trap. It looks premium in a mockup and punishing in production. Three reasons, all concrete:
#000000, you cannot represent anything below it. Shadows, pressed states, recessed wells — they all need a value darker than the surface, and #000000 leaves you none. Start at a dark grey like #121212 and you keep headroom in both directions.#000000) pixels can trail and ghost while scrolling. A dark grey keeps pixels barely lit and the motion stays clean.There is one honest exception worth naming: a deliberate pure-black "AMOLED" theme to save battery on OLED phones, where fully-off pixels draw no power. If you ship it, ship it as an opt-in variant alongside your #121212 default, not as the default — and accept that you are trading the halation and pressed-state headroom above for battery life on purpose.
Pure white text is the other half of the same mistake. Full-strength white body text on a dark surface is the thing that vibrates. The fix is to pull text back from #FFFFFF and encode your text hierarchy as opacity levels of white, not as a set of hand-picked greys:
:root[data-theme="dark"] { --text-high: rgba(255, 255, 255, 0.87); /* primary / body */ --text-medium: rgba(255, 255, 255, 0.60); /* secondary, captions */ --text-disabled: rgba(255, 255, 255, 0.38); /* disabled */}Opacity beats fixed greys because the text then composites correctly over any surface in your ramp — a lighter surface automatically gets slightly lighter-appearing text, which is exactly what you want. 87% white on a #121212 background still clears WCAG AA comfortably. You lose nothing on accessibility and you gain a screen that does not hum. One caveat: opacity-based text only composites correctly over an opaque surface. Stack a translucent-white body color over a translucent surface over an image and the math stops being predictable — flatten to opaque surfaces underneath your text and you are fine.
Take your brand's happy, saturated light-theme blue and drop it straight onto a dark surface. It will glow, buzz, and look faintly radioactive. This one catches nearly everyone.
The cause is chromatic contrast. Saturated colors carry a lot of chroma, and against a dark, near-neutral background there is nothing to temper it — the color pops out with more energy than it had on white. Highly saturated hues on dark backgrounds produce the same optical vibration you get from pure-white text, because the eye struggles to focus a fully-saturated edge against a dark field. (The physiology often cited here is chromatic aberration — the lens focuses different wavelengths at slightly different depths — and it is most punishing at the saturated blue/violet end of the spectrum.)
The fix runs against instinct if you are thinking "make it pop": you desaturate and lighten your accent colors for dark mode.
#121212 and needs to come up.:root { --accent: hsl(217, 90%, 48%); /* light theme: deep, saturated */}:root[data-theme="dark"] { --accent: hsl(217, 65%, 66%); /* dark theme: desaturated + lightened */}Material's guidance lands in the same spot — it recommends the lighter, less saturated tones from your color ramp (around the 200 tint) for dark surfaces, and it is right. If you are on a perceptual color space like OKLCH or HCT, you get this more cleanly still: hold hue and chroma sane, raise lightness, and the accent stays recognizably "the brand" while clearing contrast, because those spaces model perceived lightness rather than raw RGB.
On a fintech dashboard we shipped at Shpper, the primary CTA looked crisp in light mode and physically uncomfortable in dark. I spent an embarrassing hour convinced the problem was the surface behind it before I accepted the button itself was the culprit. Same brand, same hue family — I dropped saturation from roughly 88% to 64% and lifted lightness about fifteen points, and it settled instantly. Nobody noticed the change consciously. They just stopped squinting, and our design lead stopped forwarding me screenshots at midnight.
One thing you cannot desaturate away: semantic colors still have to read as themselves. Error red must stay unmistakably red, success green unmistakably green. Desaturate them for comfort, but verify they are still distinguishable — including for red/green color-vision deficiency, where a too-muted pair can collapse into the same murky tone.
Once you accept that dark shadows do almost nothing in the dark, you have to rebuild your depth cues. You get three levers, and elevation-via-lightness is only the first.
rgba(255, 255, 255, 0.08) up to 0.12 — separates two adjacent surfaces that sit close in value. This is how you keep a card readable against a background only a few luminance points away. In the dark, a faint light border does the job a dark shadow does in the light.A rule I follow without exception: never rely on a single depth cue. If two surfaces are one step apart on the lightness ramp and separated by a hairline border, the hierarchy survives on cheap displays, at low brightness, and for a user whose eyes are shot at 11pm. Any single cue can wash out. Two rarely wash out at once.
This same "layer light borders on top of the lightness ramp" combination is what keeps large flat regions — data tables, side panels, kanban columns — from melting into one grey plane, which is the single most common complaint I get on dense dark UIs.
This is where dark themes that nailed every token still fall apart, because assets do not answer to your CSS variables.
currentColor or CSS variables so they follow the theme:<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="M12 2 L2 7 v10 l10 5 10-5 V7 z" /></svg>
<picture> element with a media="(prefers-color-scheme: dark)" source lets you serve a different raster asset per theme without JavaScript, and in Flutter the same job is done by resolving different assets against Theme.of(context).brightness. Either way, the asset is chosen by theme, not hoped-over.filter: invert(1) hue-rotate(180deg) on images. It mangles photos, flips your brand colors to garbage, and reproduces exactly the ransom-note look we are trying to escape. Filters are a smell, not a fix. The one place they earn their keep is a single-color line diagram where a plain invert(1) genuinely reads right — and even there, test it, do not assume it.Here is the opinion that gets me pushback in planning: a proper dark mode is a design deliverable with its own assets, not an engineering switch. If it is not budgeted with real logo and illustration work up front, it is not scoped — it is a QA landmine you agreed to step on later.
The mechanics matter as much as the palette, and one bug in particular gives away an amateur implementation on sight.
Respect the OS. Read prefers-color-scheme and default to it. But also let users override manually and remember that choice — plenty of people run their whole OS in dark and still want one specific site in light. The precedence is simple: explicit user choice beats system preference beats your default.
That three-state model matters. A theme toggle should have three positions — light, dark, and system — not two. A binary switch cannot express "follow my OS," so a user who picks light on a sunny afternoon is stuck in light after their OS flips to dark at sunset. Store the state as 'light' | 'dark' | 'system', and when it is system, keep a live matchMedia listener so the page follows the OS in real time.
Now the bug that betrays everyone: the flash of wrong theme (a flash-of-unstyled-content variant). The user has dark saved, but on the very first paint the page renders light for a split second before your JavaScript boots, reads storage, and corrects it. That white flash at night is genuinely painful, and it announces "bolted on" louder than anything else you could do.
The cause: your theme decision lives in JS that runs after first paint. The fix: make the decision before first paint, with a tiny blocking inline script in the <head>, above your stylesheets.
<script> // Runs before first paint — no flash. Keep it inline and blocking. (function () { try { var saved = localStorage.getItem('theme'); var system = matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; document.documentElement.dataset.theme = saved || system; } catch (e) { document.documentElement.dataset.theme = 'light'; } })();</script>Yes, a blocking inline script feels dirty. This is the one place it is correct — it is a handful of bytes, and it is the only way to set the theme attribute before the browser paints a single pixel. On server-rendered apps (Next.js, Remix, and friends) this is also the fix for the hydration side of the problem: the server does not know the client's saved theme, so you either inline this script before hydration or accept a mismatch warning and a flash. There is no free lunch — the decision has to be made in the document, before paint.
In Flutter and native apps the equivalent is reading the saved ThemeMode synchronously at startup before the first frame, rather than defaulting to one theme and rebuilding into the other:
// Read persisted choice before runApp so the first frame is already correct.final prefs = await SharedPreferences.getInstance();final saved = prefs.getString('themeMode');final mode = ThemeMode.values.byName(saved ?? 'system');runApp(MyApp(initialThemeMode: mode));Same principle everywhere: decide before you draw.
One more: when the user toggles, transition the change or make it instant, but never animate every property on the page at once. A full-page 300ms color transition across hundreds of elements janks hard on mid-range phones. Transition the handful of surfaces that matter, or just cut. And honor prefers-reduced-motion — some users get nauseous from a full-screen crossfade, so drop straight to an instant swap when they have asked for less motion.
You cannot QA a dark theme from a code review. You have to look at it, ideally in a genuinely dark room where the flaws actually surface. Here is the pass I run every time:
#121212, accent on surface2, and so on. Numbers confirm what your eye is already telling you.matchMedia/platform listener is missing.#121212, encode text as opacity levels of white (87 / 60 / 38), and keep headroom for shadows and pressed states. Treat AMOLED-black as an opt-in variant, never the default.<picture> sources, no blanket invert filters.