Anurag
← All Posts

Why Dark Mode is Harder Than Inverting Colors

Anurag Sharma
Anurag Sharma
6 min read

"Just add a dark mode toggle, it shouldn't take more than a few days."

If you are a frontend engineer, you have probably heard a product manager utter these exact words. The assumption is understandable to a layperson: if the background is white, make it black. If the text is black, make it white. Add a little moon icon in the corner, and we can ship it by Friday.

I lived this nightmare at a mid-sized fintech startup in 2022. We tried the "quick fix" approach. We added a class to the body tag and wrote a massive CSS file overriding every hardcoded #FFFFFF with #121212.

It was a disaster. Borders disappeared into backgrounds. Drop shadows looked like dirty smudges. Our brand's primary blue, which looked crisp on a white background, vibrated aggressively against the dark grey, causing actual eye strain. The UI felt claustrophobic and broken.

Implementing a robust, scalable dark mode isn't a CSS trick. It is a fundamental architectural shift in how your application handles tokens, state, and visual hierarchy. Let's dive deep into why dark mode is so hard, and the engineering patterns required to do it right.

The Illusion of Color Inversion

The core fallacy of dark mode is that it is symmetrical to light mode. It is not.

In light mode, we create depth and hierarchy using shadows. A modal window sits "above" the page because it casts a dark drop shadow.

In a dark environment, you cannot cast a dark shadow on a dark background. It simply blends in. Instead, dark mode relies on elevation through illumination. The closer a surface is to the user (e.g., a modal on top of a card, on top of a background), the lighter its background color must be.

This asymmetry breaks naive CSS variable setups.

The Flawed Architecture

A naive approach looks like this:

:root { --bg-color: #ffffff; --text-color: #000000; --surface-color: #f5f5f5; /* A slightly darker white for cards */ } [data-theme="dark"] { --bg-color: #121212; --text-color: #ffffff; --surface-color: #000000; /* Wait, this is darker than the background! */ }

If your light mode background is white and your card is a slightly darker grey, simply inverting them means your dark mode card is darker than your background. This visually pushes the card into the screen, destroying the illusion of depth.

Architecting a Semantic Token System

To solve this, you must decouple your design values from their implementation. You need a semantic token system.

Instead of naming variables by their color (--grey-100), you name them by their architectural purpose (--surface-background-level-1).

Here is how we completely re-architected our CSS variables to support true dark mode:

1. The Primitive Layer

These are the raw values. They never change based on the theme.

:root { /* Primitives - Never change */ --blue-500: #2563eb; --blue-400: #3b82f6; --grey-0: #ffffff; --grey-50: #f9fafb; --grey-800: #1f2937; --grey-900: #111827; }

2. The Semantic Layer

These define the intent. They map to primitives and change based on the theme.

/* Light Theme (Default) */ :root { --app-background: var(--grey-50); --surface-level-1: var(--grey-0); --text-primary: var(--grey-900); --text-secondary: var(--grey-800); --brand-primary: var(--blue-500); } /* Dark Theme */ [data-theme="dark"] { --app-background: var(--grey-900); /* Notice elevation: surface is LIGHTER than background */ --surface-level-1: var(--grey-800); --text-primary: var(--grey-0); --text-secondary: var(--grey-50); /* We use a lighter blue in dark mode to maintain contrast! */ --brand-primary: var(--blue-400); }

Components consume the semantic tokens.

.card { background-color: var(--surface-level-1); color: var(--text-primary); border: 1px solid var(--border-subtle); }

The Nightmare of Contrast and Accessibility

When we implemented our dark mode, we ran into a massive accessibility hurdle. Our brand color (--blue-500) had a contrast ratio of 4.5:1 on white (passing WCAG AA), but on our dark background (#121212), the contrast dropped to a dismal 2.1:1.

You cannot use the same brand colors in both modes. You have to desaturate and lighten your primary colors in dark mode to prevent visual vibration and ensure accessibility.

We ended up writing a custom utility function in our design system build pipeline using the color2k library to automatically generate accessible dark-mode variants of our brand colors, ensuring a minimum contrast ratio of 4.5:1 against var(--app-background).

import { getContrast, lighten } from 'color2k'; function generateAccessibleDarkColor(baseColor, darkBackground) { let currentColor = baseColor; let contrast = getContrast(currentColor, darkBackground); // Iteratively lighten until accessible while (contrast < 4.5 && currentColor !== '#ffffff') { currentColor = lighten(currentColor, 0.05); contrast = getContrast(currentColor, darkBackground); } return currentColor; }

State Management and the FOUC (Flash of Unstyled Content)

The final boss of dark mode engineering is the dreaded Flash of Unstyled Content.

If you store the user's theme preference in localStorage, and your React app checks that storage on mount, you have a problem. The HTML loads, parses the CSS (defaulting to light mode), paints the screen white, then React boots up, reads localStorage, applies the data-theme="dark" attribute, and the screen violently flashes to black.

This flash is jarring and unprofessional.

To solve this, you must execute a tiny, blocking script in the <head> of your HTML document, before the body is parsed. This script must read the local storage (or system preference) and apply the correct attribute immediately.

The Blocking Script Solution (Next.js Example)

// Inside your _document.tsx or layout.tsx export default function Document() { const themeScript = ` (function() { try { var localTheme = window.localStorage.getItem('theme'); var sysTheme = window.matchMedia('(prefers-color-scheme: dark)').matches; if (localTheme === 'dark' || (!localTheme && sysTheme)) { document.documentElement.setAttribute('data-theme', 'dark'); } else { document.documentElement.setAttribute('data-theme', 'light'); } } catch (e) {} })(); `; return ( <Html lang="en"> <Head> {/* Run before CSS is painted */} <script dangerouslySetInnerHTML={{ __html: themeScript }} /> </Head> <body> <Main /> <NextScript /> </body> </Html> ); }

Dealing with Server-Side Rendering (SSR) Mismatches

If you are using Server-Side Rendering, the server doesn't have access to localStorage. If it renders light mode, but the client script immediately switches to dark mode, React will throw hydration errors because the server DOM and client DOM don't match.

We solved this by ensuring that the initial React render on the client ALWAYS matches the server (using a suppressHydrationWarning on the html tag for the specific theme attribute), and relying entirely on CSS variables to handle the visual change. The React state only updates after hydration.

Conclusion

Dark mode is not a CSS feature; it is a comprehensive design system overhaul. It requires abandoning hardcoded hex values, understanding the physics of light and shadow in UI, ensuring WCAG accessibility across multiple color spaces, and solving complex client/server hydration issues.

The next time someone says "just add a dark mode toggle," send them this article. Doing it wrong takes a day. Doing it right takes a quarter. But when you build a semantic, scalable, accessible dark mode, your architecture will be significantly more robust, and your users' eyes will thank you.

Newsletter

Occasional essays on engineering, design, and building resilient systems. No spam.