Dark mode is one of those features that sounds like "just swap a palette" until you actually ship it. I recently added dark mode to DocBeacon (a document sharing and analytics SaaS), and the implementation turned into an unexpected UI quality audit: charts, heatmaps, skeleton loaders, and even my spacing decisions were suddenly on trial.
This post is a technical walkthrough of what broke, what I changed, and the patterns I'd reuse next time.
Why dark mode is not a theme toggle
Users do not ask for dark mode because they love dark gray. They ask for it because they spend time in your product: reading analytics, reviewing dashboards, working late, switching between tabs. Dark mode is a comfort feature, but it is also a trust signal. If the UI looks inconsistent or unreadable in dark mode, the product feels less mature.
That is why I treated this as a system change, not a CSS tweak.
My implementation approach: token first, components second
The fastest path is usually to add a .dark class and override colors. That works until it does not.
What scaled for me was to define a small set of design tokens and refactor the UI to use them consistently.
I used a token approach roughly like this:
- Background layers:
bg.canvas,bg.surface,bg.elevated - Text:
text.primary,text.secondary,text.muted - Borders:
border.subtle,border.strong - Accents:
accent,accent.contrast - Semantic states:
success,warning,danger,info
Then I mapped tokens to actual colors for light and dark themes.
If you already use Tailwind, this maps nicely to CSS variables:
:root {
--bg-canvas: 255 255 255;
--bg-surface: 248 250 252;
--text-primary: 15 23 42;
--text-secondary: 51 65 85;
--border-subtle: 226 232 240;
--accent: 37 99 235;
}
.dark {
--bg-canvas: 2 6 23;
--bg-surface: 15 23 42;
--text-primary: 226 232 240;
--text-secondary: 148 163 184;
--border-subtle: 51 65 85;
--accent: 96 165 250;
}
And in Tailwind you reference them as:
.bg-canvas { background-color: rgb(var(--bg-canvas)); }
.text-primary { color: rgb(var(--text-primary)); }
.border-subtle { border-color: rgb(var(--border-subtle)); }
The point is not the exact colors. The point is that every component uses tokens, not hard-coded colors.
What broke first: visual hierarchy
In light mode, a lot of UIs get away with weak hierarchy because white backgrounds create contrast for free. In dark mode, everything compresses.
These were my main fixes:
- Reduce the number of background layers. Too many surfaces make the UI look muddy.
- Use stronger typographic hierarchy. Dark mode needs clearer separation through font weight and size, not just color.
- Avoid pure black backgrounds. Near-black works better and keeps shadows, borders, and elevations readable.
Practical rule I adopted: if a component needs a border to be visible, the surface layers are too similar.
Skeleton loaders and empty states: "invisible UI"
This surprised me. Many of my skeleton loaders were built with subtle grays that were fine in light mode, but in dark mode they basically disappeared.
Fixes:
- Use opacity-based tokens, not fixed grays.
- Ensure skeletons have enough contrast to indicate shape, not just shimmer.
A better skeleton pattern is:
- Use background based on
bg.surface - Use a slightly lighter overlay for shimmer
- Keep corners consistent with the real component
Charts: your default palette will betray you
I have charts in DocBeacon, and they were the first place dark mode exposed problems:
- Axis labels became low-contrast
- Grid lines either vanished or became too prominent
- Tooltips looked like random floating boxes
What I changed:
- Set chart text and grid colors from the same tokens as the rest of the UI.
- Make tooltips use the same surface tokens as dropdown menus.
If you use a chart library, do not accept defaults. Route all chart styling through your theme tokens, otherwise charts look like a separate product embedded inside your product.
Heatmaps and highlights: loud colors become painful
Heatmaps were the hardest part. In light mode you can use aggressive colors and still keep things readable. In dark mode, the same colors become harsh and distracting.
I ended up doing two things:
1) Rebalanced the heatmap scale in dark mode
Instead of using the same color intensity, I reduced intensity and relied more on opacity.
2) Separated background heat from selection highlight
A common issue: your heat layer and your highlight layer compete. In dark mode, that competition is amplified.
Rule that helped: heat uses opacity, highlight uses hue. Do not let both fight at the same time.
Icons, badges, and "fine until it was not"
Dark mode is where sloppy UI decisions get exposed:
- Icons that were PNGs instead of SVGs looked wrong
- Badges had hard-coded backgrounds that clashed with everything
- Focus rings were invisible
Fixes:
- Make icons inherit
currentColorwhere possible - Tokenize badge backgrounds and text
- Tokenize focus rings (do not rely on browser defaults)
Testing strategy: how I avoided shipping a broken theme
If you only toggle the theme and click around, you will miss issues.
I did a boring but effective checklist:
- Go through every major page and state: loading, empty, error, success
- Check forms: focus, hover, disabled, validation errors
- Check charts with real data (not demo data)
- Check at least one mobile viewport and one desktop viewport
- Screenshot key pages in both themes and compare side by side
If I were doing this again, I would also add visual regression tests (Playwright screenshots), because dark mode is a perfect candidate for snapshot diffs.
Takeaways
Dark mode is not colors. It is a forcing function that reveals:
- Inconsistent component styling
- Weak hierarchy
- Over-reliance on subtle borders
- Chart defaults that do not match your UI system
- Heatmap and highlight conflicts
If your product has analytics, dashboards, or any "stare at this for 10 minutes" workflow, dark mode is worth doing. Not because it is trendy, but because it raises the quality bar across the UI.
If you shipped dark mode, what was the most unexpected thing that broke in your UI?
Top comments (0)