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.
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.
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.
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.
| Surface level | Example grey | Reads as |
|---|---|---|
| Base background | #121212 | Furthest back |
| Raised card | #1e1e1e | One step up |
| Modal | #242424 | Floating above |
| Menu | #2c2c2c | Closest 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-schemeat 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.