Skip to content

Dark Mode Done Right: Color Tokens, Contrast, Depth

Dark mode isn't a color swap. Here's how to implement it in UI design with tokens, WCAG contrast, and light-based elevation that actually holds up.

· · 8 min read

Updated: July 16, 2026

Sleek dark-themed app interface displayed on a device screen

Dark mode is a second set of color tokens for the same interface, tuned so text stays legible and depth still reads on a dark surface. It is not the light theme with the colors flipped. Invert your palette blindly and you get grey text nobody can read, brand colors that vibrate, and shadows that vanish. I've shipped dark themes that took a day and dark themes that took three weeks, and the difference was always whether the color system was built for it from the start. Grid and spacing stay identical between the two themes, which is why I settle them once in the dashboard grid generator and only swap the palette.

So where do most teams go wrong? They treat dark mode as a filter you apply at the end. It isn't. It's a parallel palette, and the interface has to be built on tokens for it to work at all.

TL;DR: Dark mode isn't the light theme inverted. It's a second set of color tokens tuned for a dark surface. Build components on semantic tokens, recheck every text pair against WCAG 4.5:1, and show depth by making higher surfaces lighter grey instead of casting shadows the way you would in light mode.

Why Is Dark Mode More Than Inverting Colors?

Flip #ffffff to #000000 and #111111 to #eeeeee, and on paper you have dark mode. Load it and the problems start. Pure white body text on pure black causes halation, that smearing glow that makes text feel like it's buzzing. Your carefully tuned brand blue now looks radioactive. The subtle grey shadow that separated a card from the background is gone, because you can't cast a darker shadow on an already-dark surface.

Dark mode changes the physics of the screen. In light mode, depth comes from shadows getting darker. In dark mode, depth comes from surfaces getting lighter. That single inversion rewrites how you show hierarchy, and no automatic filter understands it.

Why Should You Start With Tokens, Not Colors?

Here's the rule that saves you: components should reference semantic tokens like color/surface and color/text-primary, never raw hex values. Each token holds two values, one per theme. Switch the theme and every component updates, because none of them know the actual color, only the role.

If your product doesn't have this yet, that's the real first task. Our step-by-step design system guide walks through building the token layer, and the design tokens Figma to code guide shows how those tokens survive the handoff to engineering. If you're starting the dark palette from scratch, the color palette generator is a quick way to draft the surface and accent steps before they become tokens. Retrofitting dark mode onto hard-coded colors means hunting through sixty components by hand. Tokens turn it into one variable swap.

Low warm light falling across a wall, most of the frame left in shadow
Photo by Guillaume TECHER on Unsplash

How Do You Get the Contrast Right?

This is where dark themes quietly fail. WCAG 2.2 still asks for 4.5:1 on normal text and 3:1 on large text, and dark surfaces make it easy to miss. That soft grey caption that passed on white can drop well below 4.5:1 on a #121212 surface. Every text-on-surface pair needs rechecking, not assuming.

Photo by Georgiy Lyamin on Unsplash

Test each combination before you commit. Our free WCAG contrast checker gives you the live ratio with pass or fail badges as you tune the colors, which beats eyeballing it. Aim for AA on body text, and treat AAA as the target for anything read in long sessions. Contrast is also a hierarchy tool, not just a compliance box, the same way it works in the visual hierarchy principles guide.

A single warm filament bulb burning against a black background
Photo by Vlad Kutepov on Unsplash

How Do You Show Elevation With Light Instead of Shadow?

In light mode a modal floats above the page on a soft shadow. In dark mode that shadow disappears into the background. So you flip the model: the higher a surface sits, the lighter its grey. Your base might be #121212, a raised card #1e1e1e, a modal #242424, a menu #2c2c2c. Each step up in the stack gets a touch brighter, and the eye reads that as closer.

Photo by dlxmedia.hu on Unsplash
Surface levelExample greyReads as
Base background#121212Furthest back
Raised card#1e1e1eOne step up
Modal#242424Floating above
Menu#2c2c2cClosest to you

This is exactly the pattern that keeps dashboard interfaces legible in the dark, where dozens of panels compete for attention and shadow alone can't separate them. Layer with light. Reserve shadow for a subtle assist, not the whole job.

What Common Mistakes Should You Skip?

A few traps I keep seeing, and how to avoid them:

  • Pure black backgrounds. Start at #121212, not #000000. Black is harsh and gives you nowhere to go for elevation.
  • Reusing light-mode accent colors. Desaturate and lighten brand colors for the dark theme, or they'll vibrate. The trap runs the other way too: palettes authored for dark surfaces, like the Apple Intelligence glow colors, fall apart on a white card without a darkened variant.
  • The white flash. Set the background color before the CSS loads and honor prefers-color-scheme at the root, so a user in dark mode never gets blinded by a white flash on load.
  • Forgetting images and illustrations. A white-background PNG looks like a hole punched in your dark UI. Use transparent assets or swap them per theme.

Type needs its own adjustments on a dark background, and those are in typography for dark mode.

A Worked Example: Retrofitting One Real Screen

Let me put numbers on it. The last product I converted was a billing dashboard with 34 components and roughly 90 hard-coded hex values scattered through the CSS. The first pass took two days and produced nothing visible, because all it did was replace those 90 hexes with 14 semantic tokens. Fourteen. That's the number that surprises people. Once you group colors by role rather than by shade, most interfaces need far fewer than the palette file suggests, and half of the 90 values turned out to be near-duplicates nobody could tell apart anyway.

The light theme kept a #ffffff page with #f7f7f8 cards. For dark I set the page to #121212 and the card to #1e1e1e, then tested. The primary text at #ffffff hit 17.4:1 against #121212, which is technically fine and visually too much, so I dropped it to #e6e6e6 and landed near 14:1. Secondary text went from #6b7280 to #a1a1aa, which moved it from a failing 3.1:1 to a passing 7.2:1. The brand blue, #2563eb in light mode, measured 3.4:1 on the dark surface and failed outright. Lightening it to #7ba7f5 brought it to 6.8:1 and, honestly, it looked better than the original.

The part that ate time wasn't color at all. It was the twelve product screenshots and three illustrated empty states, every one exported as an opaque white PNG. Re-exporting them with transparency and a theme-aware stroke color took longer than the entire token migration. Budget for that. Nobody ever does.

Dashboards are the hardest case for all of this, so they get their own write-up in dashboard dark mode patterns.

Where This Breaks

Charts break first. A data visualization with eight categorical series tuned for a white background will have at least two colors that turn to mud on #121212, and you can't just lighten all eight because then they stop being distinguishable from each other. Plan a second chart palette, not a tweak.

Third-party embeds break second. A payment iframe, a support widget, an embedded map, none of them read your tokens, and most ship a light theme you can't override. I usually give up and wrap them in a light-colored container with a bit of padding so the mismatch reads as deliberate framing rather than a bug.

Here's my opinionated take: if your product is used mostly in daylight offices, dark mode is a vanity feature and you should ship it last, after the things that actually change conversion.

Dark mode done well feels effortless, and that's the trap: it looks simple, so teams underestimate it. Build it on tokens, check every contrast pair, and let light carry your elevation. Do that, and the second theme stops being a retrofit and starts being what it should have been all along, one interface that reads well whatever the lights are doing.

Frequently Asked Questions

Should dark mode use pure black backgrounds?
No, and this is the mistake I see most often. Pure black (#000000) against pure white text hits a 21:1 contrast ratio that reads as harsh and causes halation, where light text smears against the black for people with astigmatism. Material Design recommends a dark grey around #121212 as the base surface instead. It softens the contrast, gives you room to show elevation with lighter greys, and looks far more premium than a black void. Save true black for OLED power-saving modes if you must, but even then, lift your surfaces off it.
How do I handle brand colors in dark mode?
Desaturate and lighten them. A brand blue that pops on a white background often turns muddy or vibrates painfully on a dark surface. The fix is to build a second token value for dark mode: take the saturated brand color, drop its saturation, and raise its lightness until it reads clearly at 4.5:1 against your dark surface. Do not reuse the exact light-mode hex. Material and Apple both ship separate accent values per theme for exactly this reason, and your design tokens should too.
Do I need to support both light and dark mode?
If you can only ship one theme well, ship light mode well. Dark mode is an expectation now, not a differentiator, and a broken dark theme that leaks white flashes or fails contrast does more harm than no dark theme at all. That said, respecting the user's prefers-color-scheme setting with a solid dark palette is worth the effort for any product people use at night or for long sessions. Build it on tokens from day one and the second theme costs you far less than retrofitting it later.