← Blog Nyura Blog
Pixel-Perfect Dark Mode: How Semantic Design Tokens Replaced 30 Hardcoded Colors

Pixel-Perfect Dark Mode: How Semantic Design Tokens Replaced 30 Hardcoded Colors

Hardcoded gray classes looked fine in light mode — until someone switched to dark. We replaced 30+ raw color values with semantic tokens like text-muted-foreground, bg-card, and border-border, so every element adapts automatically to any theme.

March 18, 2026 6 min read Cyril Simonnet
designdark-modecssuxaccessibility

The Problem with Hardcoded Colors

When you build a UI quickly, it is tempting to reach for raw Tailwind color classes like text-gray-500, bg-gray-100, or border-gray-200. They look great in light mode. The problem appears the moment a user toggles their system preference to dark mode, or taps the theme switcher inside your app. That soft gray-100 background becomes invisible against a dark canvas. That gray-500 text that provided subtle contrast on white now vanishes into a dark gray background. Borders disappear entirely. Cards lose their visual separation from the page. In Nyura, we accumulated over 30 instances of hardcoded gray values scattered across components — task cards, navigation bars, settings panels, modal dialogs, empty states, skeleton loaders, and more. Each one was a small paper cut. Individually, a slightly wrong shade of gray is barely noticeable. But collectively, they created an experience in dark mode that felt unfinished, inconsistent, and broken. Users who preferred dark mode — and that is a growing majority on mobile — were getting a second-class experience despite the app having a perfectly functional theme system under the hood. The root cause was simple: we were encoding visual appearance directly instead of encoding intent. When you write text-gray-500, you are saying "make this text this exact shade of gray." When you write text-muted-foreground, you are saying "make this text the color that means secondary, de-emphasized content" — and the theme system handles the rest. That single conceptual shift changed everything.

Semantic Tokens: Encoding Intent Instead of Appearance

The migration was methodical. We audited every component in the codebase and identified four categories of hardcoded color values that needed replacement. First, text colors. Every instance of text-gray-400, text-gray-500, and text-gray-600 was replaced with text-muted-foreground. This single token resolves to a soft gray in light mode and a lighter gray in dark mode, maintaining readable contrast in both themes without any conditional logic. Secondary headings, timestamps, helper text, placeholder labels — all unified under one semantic token. Second, background colors. Instances of bg-gray-50, bg-gray-100, and bg-white were replaced with bg-card for card surfaces, bg-muted for subtle background areas like sidebars and grouped sections, and bg-background for the page canvas itself. The visual hierarchy remains identical in light mode, but in dark mode everything slots into the correct dark palette automatically. Third, borders. Every border-gray-200 and border-gray-300 became border-border, a single token that adapts its opacity and hue to the active theme. Cards, dividers, input fields, and table rows all gained consistent edge definition in both modes. Fourth, interactive states. Hover backgrounds that were hardcoded as hover:bg-gray-100 became hover:bg-muted, ensuring that hover feedback is visible regardless of the base theme. The beauty of this approach is that it is purely additive from a design perspective. In light mode, the visual output is pixel-identical to what existed before. The only difference is that dark mode now works flawlessly, and any future theme — high contrast, sepia, or custom brand themes — will work automatically because every component speaks the language of intent rather than the language of specific color values.

The Transition-Colors Polish and Skeleton Loading States

Replacing colors was only half the story. Once every element used the correct semantic token, we noticed that theme switching felt abrupt. Toggling from light to dark mode caused an instant, jarring snap as every color on the screen changed simultaneously. The fix was adding transition-colors to over 20 interactive elements — buttons, cards, list items, navigation links, sidebar entries, tab indicators, and badge components. With a 150-millisecond CSS transition on color properties, theme switching became a smooth, coordinated fade rather than a binary flip. But transition-colors delivered a second, unexpected benefit: hover states. Before the transition classes, hovering over a button caused an instant background color change. After adding transition-colors, hovers became silky smooth. The background eases in over 150 milliseconds, creating a polished feel that users notice subconsciously. It is one of those details that nobody can point to specifically, but everyone feels when it is missing. We applied the same treatment to focus-visible rings. When a keyboard user tabs to a button, the focus ring now fades in smoothly rather than popping into existence. This makes keyboard navigation feel intentional and designed, not like a browser default that was accidentally left visible. Alongside the color migration, we replaced raw loading spinners with skeleton loading states throughout the app. Instead of a spinning circle that tells users nothing about what is coming, skeleton loaders render gray placeholder shapes that mirror the layout of the content being loaded — card outlines, text line widths, avatar circles. When the real data arrives, it replaces the skeleton with zero layout shift. This eliminates the jarring reflow that spinners cause when content finally appears at a different size than expected. The skeletons also use the semantic bg-muted token, so they look correct in both light and dark mode without any special handling.

Results: A Unified Visual Experience Across Every Theme

The numbers tell the story clearly. Over 30 hardcoded gray color classes replaced with four semantic tokens: text-muted-foreground, bg-muted, bg-card, and border-border. Over 20 interactive elements gained transition-colors for smooth hover animations and seamless theme switching. Raw loading spinners eliminated in favor of skeleton loading states that preview the incoming layout and use semantic colors. Focus-visible keyboard accessibility rings added with smooth fade-in transitions, making keyboard navigation feel first-class rather than an afterthought. The qualitative impact is even more significant. Dark mode users — who represent a large and growing share of our mobile audience — now see an interface that looks intentionally designed for dark mode, not one that was bolted on as an afterthought. Every card, every border, every piece of secondary text adapts naturally. There are no more patches of wrong-contrast gray. No more borders that vanish. No more hover states that are invisible against the background. The maintenance benefit is equally important. Before the migration, adding a new component required the developer to remember which gray values to use and to manually test in both themes. Now, using the semantic tokens is the path of least resistance: bg-card, text-muted-foreground, border-border. The correct answer is also the easiest answer, which means new components are theme-correct by default. If we ever introduce additional themes — high contrast for accessibility, a sepia reading mode, or custom brand themes for enterprise clients — every existing component will adapt automatically because none of them contain hardcoded color assumptions. The design system speaks in intent, and intent is universal across themes. This is what pixel-perfect dark mode actually means: not manually tweaking every shade for every theme, but building a token system where perfection is the default outcome.

Try Nyura for free

Available on iOS, Android, and web. No credit card required.

Get Started →