Skip to content
Sudip KC writing notes in a journal at a desk
SK.
← All articles

UI/UX · · 7 min read

Dark Mode Isn't Just Inverting Colors

  • UI/UX
  • Design Systems
  • Tailwind CSS
  • Accessibility

Flip white to black, black to white, ship it. That is how most dark modes start, and it is exactly why so many of them feel harsh, flat, and slightly broken at 11pm when someone actually uses them.

I have built dark themes for dashboards, landing pages, and my own portfolio, and the biggest lesson is that dark mode is a second color system, not a filter. It needs its own decisions about depth, contrast, brand color, and imagery. You can get away with a quick inversion for a weekend project. You cannot get away with it on a product people stare at for a full shift.

Here is how I think about it now, and the setup I use in Tailwind CSS v4 to keep both themes honest.

Why inversion fails

When you invert a light UI, a few things go wrong at once.

  • Pure black backgrounds with pure white text vibrate. The contrast is technically excellent and practically tiring. Thin fonts start to bloom and smear, especially on OLED screens.
  • Shadows stop working. In light mode, elevation comes from shadows. On a near-black background, a dark shadow is invisible, so every card looks like it is pasted onto the same plane.
  • Brand colors get loud. A saturated blue that sits politely on white turns neon on black. Buttons start shouting.
  • Semantic colors drift. The red you picked for errors may pass contrast on white and fail on dark gray, or the reverse.
  • Images and illustrations look wrong. Screenshots with white backgrounds become glowing rectangles in the middle of a dark page.

None of these are solved by a CSS filter: invert(1). They are solved by designing the dark palette on purpose.

Start from tokens, not from colors

The single most useful decision is to stop referring to colors by what they look like and start referring to them by what they do. Not gray-900, but surface. Not white, but text-primary. Once components only speak in roles, a theme is just a different mapping of roles to values.

My base set is small:

  • bg for the page background
  • surface and surface-raised for cards, popovers, and modals
  • border and border-strong
  • text, text-muted, and text-subtle
  • accent and accent-contrast for the text that sits on top of the accent
  • success, warning, danger, each with a soft background variant

That is about fifteen tokens. Every component in the system uses only those. If a designer or developer reaches for a raw hex value inside a component, it gets flagged in review.

In Tailwind v4 the setup lives in CSS, which I like, because the theme is just variables:

@import "tailwindcss";

@custom-variant dark (&:where([data-theme="dark"], [data-theme="dark"] *));

:root {
  --bg: oklch(98.5% 0.003 250);
  --surface: oklch(100% 0 0);
  --surface-raised: oklch(100% 0 0);
  --border: oklch(91% 0.005 250);
  --text: oklch(22% 0.01 250);
  --text-muted: oklch(45% 0.01 250);
  --accent: oklch(55% 0.18 255);
  --accent-contrast: oklch(99% 0 0);
}

[data-theme="dark"] {
  --bg: oklch(17% 0.01 250);
  --surface: oklch(21% 0.01 250);
  --surface-raised: oklch(25% 0.012 250);
  --border: oklch(31% 0.01 250);
  --text: oklch(93% 0.005 250);
  --text-muted: oklch(72% 0.01 250);
  --accent: oklch(72% 0.14 255);
  --accent-contrast: oklch(18% 0.02 255);
}

@theme inline {
  --color-bg: var(--bg);
  --color-surface: var(--surface);
  --color-surface-raised: var(--surface-raised);
  --color-border: var(--border);
  --color-text: var(--text);
  --color-text-muted: var(--text-muted);
  --color-accent: var(--accent);
  --color-accent-contrast: var(--accent-contrast);
}

Now bg-surface text-text border-border works in both themes with no dark: prefixes sprinkled through every component. I still keep the dark variant around for rare one-offs, like swapping an illustration.

A few things are worth noticing in those values.

The decisions that actually matter

The background is not black

I use a dark gray with a slight cool tint instead of #000. It lowers the harshness, and it leaves room below the background for depth. The text is not pure white either. Around 93% lightness reads clearly without glaring.

Elevation goes up in lightness

In light mode, raised things get shadows. In dark mode, raised things get lighter. The page is darkest, cards are a step lighter, popovers and modals are lighter again. That one rule fixes the flat, everything-on-one-plane look more than anything else. I still keep a subtle shadow on modals, but the lightness step is doing the real work.

The accent gets lighter and less saturated

Notice the accent moves from 55% lightness to 72%, and the chroma drops. On a dark background, the same saturated blue would be too intense and its text would fail contrast. A lighter, calmer accent keeps the brand recognizable without turning every button into a sign. That also means the text on the accent flips from white to near-black, which is why accent-contrast exists as its own token.

Semantic colors get their own pass

I check every status color against the surface it actually sits on in both themes. Error red on a dark card is usually fine at a lighter shade. Warning yellow is the troublemaker: it is easy on dark backgrounds and hard on light ones, so the light theme usually needs a darker amber for text.

A dark theme is a second palette that happens to share names with the first one. Treat it as a design task, not a CSS task.

Contrast is a requirement, not a vibe

When I worked on the palette tooling at Tonalist Color Revolution, the target was WCAG AA, and that habit stuck. Every text token gets checked against every surface it can land on, in both themes:

  • Body text against bg, surface, and surface-raised
  • Muted text against the same three (this is where most dark themes fail)
  • Accent text and accent-contrast on the accent itself
  • Each semantic color on its soft background

AA asks for 4.5:1 for normal text and 3:1 for large text and meaningful UI parts like input borders and focus rings. That last part matters in dark mode. A 1px border that was obvious on white can disappear on a dark surface, and an input with no visible edge is an accessibility bug, not a style choice. The MDN page on color contrast is a good reference to keep open.

Muted text deserves a special mention. Designers love low-contrast secondary text because it looks refined in a Figma frame. On a real dark screen at low brightness, it simply vanishes. I would rather have slightly less elegant metadata than metadata nobody can read.

Images, charts, and the small stuff

The palette is half the job. The rest is everything that is not a token.

  • Screenshots and illustrations: if an image has a white background baked in, either provide a dark variant or put it inside a framed card so it reads as an intentional object rather than a hole in the page. Slightly dimming large photos in dark mode with a low opacity overlay also helps.
  • Logos: a dark logo on a dark background disappears. Keep a light version and swap it.
  • Charts: gridlines that were light gray become too bright on dark. Chart colors need their own dark variants, the same way the accent does. I covered some of this in How I Design Dashboards for Complex Applications.
  • Focus rings: test them. A blue ring tuned for white often blends into a dark blue-gray surface.
  • Native controls: set color-scheme: light dark (or the matching value per theme) so scrollbars, date pickers, and form controls follow the theme instead of flashing white.

Getting the toggle right

The theme switch itself has three states, not two: light, dark, and system. Defaulting to the system preference respects what the user already chose for their device. The manual override is for the person who wants a dark editor and a light everything-else.

The classic bug is the flash: the page renders light, JavaScript loads, then it snaps to dark. The fix is a tiny inline script in the document head that reads the saved preference and sets data-theme before the first paint. It has to run before React hydrates, so it cannot live inside a component effect.

Then store the choice somewhere cheap, like localStorage, and listen for the prefers-color-scheme media query to change when the user is on the system setting.

How I test it

Before calling a dark theme done, I go through a short list:

  1. Use the product in dark mode for a full working session, not a two-minute glance.
  2. Drop screen brightness low and check muted text, borders, and disabled states.
  3. Run every text token through a contrast checker against every surface.
  4. Open every modal, dropdown, and toast to confirm elevation reads correctly.
  5. Check empty states and error states, which are always the last screens anyone themes. See Handling Loading, Error and Empty States Properly.

The short version

Dark mode is not the light theme with the lights off. Build both themes on the same set of role-based tokens, let elevation come from lightness instead of shadow, calm down your accent, and check contrast for every pairing that can actually happen. Do that, and the dark theme stops being the version users tolerate and becomes the one they pick on purpose.