The difference between a dark mode that's easy to maintain and one that becomes a permanent source of bugs almost always comes down to how the underlying CSS variables were structured on day one. Get the structure right, and adding dark mode support to a new component is nearly free. Get it wrong, and every new component needs its own light and dark styles written by hand.
Step 1: Separate raw colors from semantic roles
Define your raw color values, specific hex or HSL values, in one layer, and define semantic role variables, --color-background, --color-text-primary, --color-border, in a second layer that references the raw values. Components should only ever reference the semantic variables, never the raw color values directly, since the semantic layer is what actually changes between themes.
This two-layer approach is the single most important structural decision in the entire system. Without it, "supporting dark mode" means finding and updating every hardcoded color reference across your codebase instead of updating one small block of variable definitions.
Step 2: Scope the theme override to a root selector
Define your light theme values as the default on :root, then override the same semantic variable names inside a [data-theme="dark"] selector (or an equivalent class-based approach) applied to the same root element. Because components only reference the semantic variable names, and never know which theme is active, switching the attribute on the root element instantly re-themes the entire page without touching a single component's own styles.
MDN's documentation on CSS custom properties covers the cascade behavior this relies on in more detail, and it's worth understanding the cascade specifically, since a common mistake is defining theme overrides at a specificity level that unintentionally gets overridden by a more specific component selector elsewhere.
Step 3: Name variables by role, not by appearance
A variable named --gray-600 describes what the color looks like, not what it's for, which becomes a problem the moment you need that role to be a different literal color in dark mode. A variable named --color-text-secondary describes its role regardless of what the underlying value is in either theme, which is exactly the abstraction that makes a theme switch possible without renaming anything at the component level.
This naming discipline takes a small amount of upfront thought but pays for itself immediately the first time a designer decides gray-600 should actually be a slightly blue-tinted gray in dark mode specifically, a change that becomes a one-line edit instead of a search across every component that referenced the old name.
Step 4: Build the elevation scale as its own variable set
As covered in our dark mode design guide, dark interfaces communicate elevation through lightness rather than shadow, which means you need a set of surface variables, --surface-base, --surface-raised, --surface-overlay, each mapped to a different value per theme. Structuring these as their own named scale, separate from your background and text variables, keeps the elevation system easy to reason about as new surface levels get added.
Step 5: Handle third-party components with a wrapper layer
Components you don't control, embedded widgets, charting libraries, payment forms, usually can't consume your CSS custom properties directly. Wrap them in a container that reads your semantic variables and passes the resolved values into whatever configuration API the third-party component expects, rather than trying to force CSS variable inheritance onto code that wasn't built to support it.
This wrapper pattern isolates the awkward part of third-party theming into one well-understood location instead of scattering special-case logic throughout your codebase wherever a third-party component happens to be used.
Step 6: Test the system before building on top of it
Before writing dozens of components against this variable structure, build two or three representative components, a card, a button, a form input, and verify the theme switch works cleanly across all of them with zero component-level changes required. Catching a structural gap at this stage costs an afternoon; catching it after fifty components have been built against a flawed structure costs a much larger refactor.
The payoff
A well-structured variable system turns "add dark mode to this new component" from a design and engineering task into something that happens automatically, as long as the component was built using semantic variables from the start. That's the actual goal, not a specific color palette, and it's worth the extra planning time before the first component gets built rather than retrofitted in afterward.
Step 7: Add a lint rule to enforce the discipline
The structural approach above only works if every component actually follows it, and a large enough codebase will eventually have someone reach for a raw hex value out of habit or under deadline pressure. A stylelint rule that flags raw color values outside the designated token file catches this automatically in code review, turning a discipline that would otherwise rely purely on developer memory into something enforced by tooling.
This is a small addition, but it's the difference between a token system that stays clean for the life of a product and one that slowly accumulates exceptions until the original structure barely matters anymore.
Step 8: Handle color-mix and computed variants carefully
Some designs need computed variants of a base color, a slightly lighter hover state, a slightly transparent overlay version. Modern CSS's color-mix() function, documented on MDN, can derive these from your semantic variables at the CSS level rather than requiring a second hardcoded variable for every hover and active state, which keeps the token file smaller and the relationship between a base color and its variants explicit rather than duplicated.
Older browser support for color-mix() varies, so check your specific support requirements before relying on it exclusively, and have a fallback approach, precomputed hover-state variables defined alongside the base ones, for any browser versions your product still needs to support.
Step 9: Version the token file like any other shared dependency
Once other engineers are building against your semantic variable names, treat changes to that file with the same care you'd apply to any shared API. Renaming a variable, or changing what it resolves to in a way that shifts visual behavior broadly, deserves a deliberate review rather than a quick, unreviewed edit, since a single change here can ripple across every component in the product at once, for better or worse. web.dev's design tokens guidance covers a similar versioning discipline for teams maintaining a shared token system across multiple products or teams.
Step 10: Plan for a third theme before you need one
Even if your product only ships light and dark today, structuring the variable system around named semantic roles rather than a hardcoded two-theme assumption makes adding a third theme, a high-contrast accessibility mode, a seasonal or branded theme, a matter of defining one more set of overrides rather than reworking the underlying architecture. This costs almost nothing extra to plan for upfront and can save a genuinely large refactor if a third theme becomes a requirement later.
Step 11: Keep an eye on runtime performance for very large token sets
Products with an unusually large number of semantic variables, hundreds rather than dozens, can see a measurable difference in style recalculation performance when switching themes, particularly on lower-powered devices. This is a rare concern for most products, but worth a quick performance profile if your token system has grown considerably larger than a typical design system, since the fix, usually consolidating redundant variables, is much easier to apply before the system has been built on top of extensively.
Putting it all together
None of these eleven steps is individually difficult, but skipping the structural ones early, semantic naming, the two-layer color architecture, a lint rule to enforce it, tends to cost considerably more time later than it would have taken to set up correctly from the start. Treat the token structure itself as a piece of infrastructure worth getting right before building the first component on top of it, not an afterthought to patch once dark mode is already partially built.
Top comments (0)