devShakib

Dark mode is not an inverted stylesheet

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.

Why inverting your light theme looks broken

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:

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.

Elevation in the dark: surfaces get lighter, not shadows darker

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.

The overlay trick: one base color, one function

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.

Killing pure black and pure white

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:

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.

Desaturating accents so they do not vibrate

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.

: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.

Shadows, borders, and depth without light

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.

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.

Images, logos, and illustrations that assume a white canvas

This is where dark themes that nailed every token still fall apart, because assets do not answer to your CSS variables.

<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>

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.

System preference, override, and the flash-of-wrong-theme bug

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.

A perceptual QA pass before you ship

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:

Key takeaways