Skip to content
Back to Learn
DesignJuly 26, 20266 min read

Designing a dark-first design system

SIGNAL, the system this site runs on: why dark is the primary act, why tokens carry roles instead of values, why pure black is a trap, and why there is exactly one accent.

Kushagra SharmaFounder and product engineer

This site runs on a design system called SIGNAL: a graphite ramp, a single electric-lime accent, and two acts, dark and light. Dark was designed first and light second, deliberately, in that order. Below is the reasoning behind the tokens, the contrast traps we hit, and why there is exactly one accent colour. Everything quoted here lives in one file, src/app/globals.css, so none of it has to be taken on trust.

Dark-first is a constraint, not a coat of paint

Most design systems are built light and then inverted. You define a palette against white, ship it, and three sprints later somebody bolts on a dark theme by flipping a few variables. It looks fine in the design file and falls apart in production: pure black panels, grey text that vanishes, shadows that do nothing because there is no light to cast them.

Building the other way round forces the better decisions early.

  • You meet the low-contrast failure modes first, when they are still cheap to fix.
  • Elevation has to be expressed with surface colour rather than shadow, which is a more honest model anyway.
  • The accent gets chosen against a dark field, where saturated colour behaves very differently than it does on white.

The acts framing is not decoration. An act has its own lighting and its own mood, but it is the same story with the same characters. The two acts share structure and intent. What changes is the staging.

Tokens carry meaning, not values

The mistake that kills theming is naming tokens after what they look like. --gray-900. --blue-500. The moment you do that, the two themes cannot share a line of component code, because the component now knows a literal colour.

Every semantic token in SIGNAL is named after its job. A component asks for --surface, --muted or --border. It never asks for a hex, and it never knows which act it is in. The act decides what those roles resolve to.

A component should describe what it needs, not what it wants to look like. "Give me the raised surface colour" is a contract. "Give me #1a1a1a" is a liability.

There are two layers underneath that, and keeping them apart is what makes the system tractable. A raw ramp holds the values (--color-n-850, --color-lime-500) and nothing outside the token file may reference it. A semantic layer maps roles onto that ramp, once per act, and that is the only layer components ever see.

Pure black is a trap

The first instinct in a dark theme is to reach for #000000. It costs you three separate things.

On OLED panels, pure black under bright text produces visible smearing as the eye moves, so text edges feel like they are vibrating. It leaves nowhere to go underneath: if the base is black you have spent the bottom of your range before you started, and every elevation step has to climb. And it flattens shadow, because a shadow is just a darker region and nothing is darker than black.

SIGNAL's canvas is #0a0b0d, a few points off black, and the surface scale moves off it rather than under it: #15171c is the raised surface a card is drawn on, and #1c1f25 is the recessed step used inside one, for a code block or a pulled quote. Depth reads from surface colour alone, which means shadow gets to be an accent rather than a crutch.

The other trap is contrast in the wrong direction. The WCAG minimum of 4.5:1 for body text is a floor, not a target, and on a very dark background maximum contrast is actively unpleasant. Pure white on #0a0b0d is 19.69:1 and fatiguing over a long session, so SIGNAL's body text is #e9ecef, which lands at 16.60:1 on the same base and is easier to sit with. Both figures are the standard contrast ratio between the two hex values named in this paragraph. Any contrast checker will reproduce them.

The tokens, and how they flip

Components reference semantic variables. An act is a scope that redefines what those variables resolve to. No component code changes between them.

/* Act one: dark is the default, declared on :root */
:root {
  --bg:        #0a0b0d; /* a few points off black, never #000 */
  --surface:   #15171c; /* raised: the card, the panel, the control */
  --surface-2: #1c1f25; /* recessed: the step used INSIDE a raised surface */
  --text:      #e9ecef; /* deliberately not pure white */
  --muted:     #969da8;
  --border:    #2c313a;
  --accent:    #c8ff3d; /* electric lime, tuned for a dark field */
}

/* Act two: light is designed, not derived by inverting act one */
[data-act="light"] {
  --bg:        #f6f7f9;
  --surface:   #ffffff;
  --surface-2: #e9ecef; /* same recessed role, reached by going DARKER */
  --text:      #0f1115;
  --muted:     #565c66;
  --border:    #d8dce2;
  --accent:    #97d60f; /* same family, denser: lime washes out on white */
}

In the real file each of those values comes from a named ramp step rather than a literal, so the same graphite is never typed twice. The resolved values are shown here because they are what you actually have to reason about.

Two things are worth noticing. --surface-2 holds one role in both acts, the recessed step inside a raised surface, but it reaches that role from opposite directions: lighter than --surface in dark, darker than it in light. The role is stable and the physics flip underneath it. And the accent is not the same value in both. #c8ff3d reads clean on near-black and washes out on white, so the light act drops to a denser #97d60f at the same hue. Roles stay constant, values get tuned per act.

One accent, and the exception that proves it

The temptation in any design system is to ship a rainbow: a primary, a secondary, an informational blue, a gradient for the marketing page. SIGNAL ships one accent and treats it as a scarce resource.

  • It makes hierarchy unambiguous. When one thing is coloured, the coloured thing is the thing. A second accent starts a competition for attention that the reader has to referee.
  • It stays tractable across acts. Tuning one hue against two backgrounds is a job you finish. Tuning five is a permanent source of inconsistency.
  • It leaves the semantic colours, success and warning and danger, room to actually mean something, because they are not fighting decoration for the reader's eye.

The exception is the instructive part. The focus ring is its own token rather than an alias of the accent, because a focus indicator is a non-text contrast target: WCAG 2.2 asks for 3:1 against whatever it sits on, and the light act's accent manages 1.64:1 there. So the light act's focus ring uses a much darker chartreuse, #3f5c00, which clears the requirement at 7.14:1.

That is what single-accent discipline actually looks like in practice. Not one hex everywhere, but one meaning, expressed with whatever value clears the bar in the context it lands in. Where a token has to diverge, the reason is written next to it in the file, because a divergence nobody documented is a divergence somebody will later copy.

What the discipline buys

Get the semantic layer right and theming stops being a refactor and becomes a data problem. A third act, a genuine high-contrast staging, is a new scope with new values in it rather than a second component library. Restraint reads as confidence for the same structural reason: a system that uses colour sparingly looks like it knows what matters, because it had to decide.

That is the whole return on writing tokens as roles. The components were never the part that had to change.

Share this article

Need a product built?

Describe the problem in your own terms. You will get a straight answer on what building it actually takes, and how an engagement would run.