← All writing

Building a design system that works in both light and dark

A design system that works in light and dark needs one set of named roles and two sets of values, rather than a second system built from scratch.

A design system works in both light and dark when the two themes share one set of named roles, background, ink, accent, support, line, and only the values behind those roles change between them. The failure mode is building the two themes as if they were separate systems that happen to live in the same codebase: one gets tuned carefully, the other gets a quick invert, and the pair drifts apart every time someone fixes something in only the theme they were looking at.

This site runs that way on purpose. Dark is the default, written once as a set of named roles in the design foundations. Light is not a second file of decisions sitting next to the first one. It is the same role names, given a different set of values, plus a short and honest list of the few places that still need their own call.

One set of roles for both themes

Start with the roles before the colors. Background, surface, ink, muted text, accent, support, and the line that separates one area from another: each of those is a question a component asks, never a hard coded answer it already knows. A button does not say "my text is near white." It says "my text is the ink role," and the current theme decides what that resolves to.

Written this way, a theme is not a different design. It is a different answer to the same short list of questions. The dark values live as the defaults. The light theme overrides exactly those same role names under a theme attribute on the page, nothing more. A component built against the roles never has to know which theme it is rendering in. It only has to know which role it is asking for.

You can see the full set of roles and both their value sets on the live system behind this site, named and laid out as the actual foundations the rest of the site builds on rather than a description of them after the fact.

Dark default and light override, same roles

Dark, the default

Background sits near black with a trace of navy. Ink is a soft near-white. The surfaces step up through lightness instead of shadow.

Same role names in both themes

Light, the override

Background turns white. Ink turns a deep navy. The same surfaces step up by moving slightly cooler and darker instead.

Nothing is inverted. Every role keeps its job and gets a value chosen for its own background.

Why the values cannot simply invert

The tempting shortcut is to flip every value: white becomes black, black becomes white, and the accent stays exactly as it was. It almost works, and that is what makes it dangerous. The one place it reliably fails is the accent itself.

An orange tuned to sit comfortably on a near black background is usually too bright and too thin to clear a readable contrast against white text's opposite case, text set in that orange on a white page. I have written separately about why dark surfaces need their own contrast decisions rather than an inverted light theme, and the same logic runs the other direction too. On this site the accent and its supporting blue both get deepened specifically for the light page, so a sentence set in either color still clears the WCAG contrast floor rather than just looking close enough.

That is the real argument for roles over raw values. A role can hold two different values, one per theme, each chosen for the background it actually sits on. A straight invert cannot do that, because it assumes the only thing that changed is which end of the scale is light. What actually changed is the whole relationship between a color and the surface behind it, and that relationship has to be re-decided rather than flipped.

The exceptions you name on purpose

A role based system does not remove every special case. It does something more useful: it makes the special cases rare, and it forces you to name them instead of letting them hide as dead code nobody remembers writing.

On this site there are a small number of places that do not follow the shared roles cleanly, and each one is written down as a deliberate override rather than left to accident.

Each of those is one override, scoped to the one place it is needed, with a comment next to it saying why. That is the same discipline I have argued belongs next to every foundational decision: the reason lives with the rule, so the next person to touch it can tell whether it is still true rather than re-litigating it from scratch. A system with three named exceptions is healthy. A system where every third component needs one is telling you the roles were drawn in the wrong place.

A role is a promise kept in every theme. A value is the only thing allowed to change.

Getting that balance right, mostly shared roles with a short, visible list of named exceptions, is a large part of what my design systems architecture work actually is: going into a system that already has light and dark drifting apart, and closing the gap without hand tuning every screen a second time.

Takeaways

Name the roles before you pick the colors: background, ink, accent, support, line, decided once and reused by every component regardless of theme. Let the values differ fully per theme rather than inverting them, because a color chosen for a dark surface and a color chosen for a white one are answering different questions, especially the accent. Keep the handful of real exceptions, but name every one of them with its reason written down, so the system stays two value sets rather than quietly becoming two systems.

A design system that works in light and dark is not two designs. It is one set of decisions, answered twice.

Questions people ask about light and dark design systems

Can I generate a light theme automatically from a dark one?

You can generate a starting point, but treat it as a draft to correct rather than a finished theme. The background and ink usually invert cleanly. The accent rarely does, because a color tuned to read well on a dark surface is not the same color that clears contrast on a white one.

Do light and dark need separate accent colors?

The roles stay shared. Only the values behind them differ. The accent keeps its job, marking the same primary actions in both themes, but the exact value shifts so it still reads clearly against whichever background it actually sits on.

What do I do when a component will not follow the shared roles?

Give it its own named override, written down with the reason next to it, rather than letting it become a silent exception nobody can explain later. A handful of those across a whole system is normal. Needing one in most components means the roles were not drawn in the right place.

ShareXLinkedIn
← PreviousUX microcopy examples: the words that make an interface trustworthyNext →Mobile app UI design principles, learned from a screen nobody can look at twice

Keep reading