Skip to main content

N2-02 · How a color becomes a token

Context

You declare #C40145 in a theme config. A fraction of a second later, there are 34 tokens derived from that color — complete palette, neutrals, hover and active behaviors, dark mode version. All calculated.

How does this happen? And why can the generated hex be slightly different from the original hex?

This tutorial answers those questions without equations — with designer intuition.


Concept

Why not use HSL?

Before explaining what the system uses, it helps to understand the problem with what most systems use.

HSL (Hue, Saturation, Lightness) seems intuitive: you change L to lighten or darken a color. But "lighten 10% in HSL" does not produce visually uniform results across different hues. A blue darkened by 10% looks much darker than a yellow darkened by 10% — even though the numbers are identical.

The result: HSL palettes are mathematically consistent but visually irregular.

OKLCh — the perceptually uniform color space

OKLCh (Lightness, Chroma, hue) is a color space built to match human perception. In it:

  • L = 0.5 actually looks "half-bright" — not calculated as, but perceived as
  • Lightening 10% produces visually equivalent results across any hue
  • Darkening is equally predictable — the system can guarantee that each palette level looks uniformly different from the previous

This is what allows the engine to generate consistent palettes from any brand color — whether it is a vibrant red or a soft green.


What happens to your hex

When you declare #C40145 in the config, the engine runs this process:

Step 1 — Conversion to OKLCh

The hex is converted from RGB to the OKLCh color space. Here, the color has three dimensions:

  • L — how light it is (0 = black, 1 = white)
  • C — how saturated it is (0 = gray, high values = vibrant)
  • h — the hue (the angle on the color wheel)

Step 2 — Palette generation (19 levels)

The engine fixes the hue (h) and varies L in 19 uniform steps, from lightest (level 10) to darkest (level 190). Level 100 is the original color — it is not altered.

Each level has three properties: surface (the background), txtOn (the accessible text color over that background), and border (the derived border color). Since 3.6.0, the engine also generates a fourth property txt per level — readable text color for content on canvas (not on the element's own background). See txt token.

Step 3 — Neutrals generation (15 levels)

Neutrals follow the same L variation process, but with C (saturation) reduced to approximately 10% of the original. Result: grays lightly tinted by the brand color — more harmonious than a pure gray, but without dominating the interface.

Step 4 — Dark mode

The dark mode palette is generated by inverting the indices: level 10 in dark corresponds to level 190 in light, and vice versa. Level 100 stays the same. Additionally, C for all of dark mode is multiplied by 0.85 (default) — 15% less saturated, reducing visual fatigue in dark environments.

Step 5 — Conversion back to hex

Each color generated in OKLCh is converted back to hex. Because of the round-trip conversions (RGB → OKLCh → operations → OKLCh → RGB), the hex at level 100 may be slightly different from the hex originally declared in the config. One or two digit differences are normal and expected — it is not a bug.


Why this matters for the System Designer

You do not need to manually choose each palette level. Declare the correct brand color, and the engine delivers 19 palette levels + 15 neutral levels + the complete dark mode structure.

You do not need to check the contrast of each level. The txtOn for each level is calculated to pass WCAG AA (4.5:1) over that level's surface. The calculation happens in OKLCh, where predictability is real.

Palettes are mathematically harmonious with each other. When you have three brand colors, the three palette sets are calculated by the same pipeline. Visual harmony is guaranteed by mathematics, not by the designer's eye.


Guided example

Reading a generated palette

After running the build, the file data/brand/{theme}/_primitive_theme.json contains the complete palette. For primary color #C40145:

Level 10 (lightest): surface #FFF0F3, txtOn #8B0030, border #FBCED8
Level 50: surface #FFC8D3, txtOn #8B0030, border #F5A0B0
Level 100 (original): surface #C40145, txtOn #FFFFFF, border #9C0136
Level 150: surface #6B0025, txtOn #FFFFFF, border #540019
Level 190 (darkest): surface #1A0009, txtOn #FFFFFF, border #0D0005

In dark mode, the inversion is:

dark[level 10] = light[level 190] → #1A0009 (now the most "subtle" in dark)
dark[level 100] = light[level 100] → #C40145 (the base color does not change)
dark[level 190] = light[level 10] → #FFF0F3 (now the most "intense" in dark)

So semantic.color.interface.function.primary.normal.background in light mode and dark mode point to different levels of the same palette — calculated so that both visually appear to be the brand's primary color in the correct context.


Now you try

Consider a hypothetical feedback color — success — declared as #22C55E (green).

  1. Will level 50 of this palette (very light) have a white or black txtOn? Why?
  2. In dark mode, which light level becomes the "most subtle" success background?
  3. If you want the success badge to be discreet (very soft background), which intensity to use — lowest, default, or highest?

Expected result:

  1. Black txtOn — level 50 is very light (high L), and dark text has more contrast over light backgrounds. The engine calculates this automatically.
  2. dark[10] = light[190] — the darkest level of light becomes the most subtle background in dark (and it makes sense: a dark green on a dark background is subtlety).
  3. lowest — it is the lowest intensity level, producing the softest background.

Checkpoint

By the end of this tutorial you should know:

  • Why OKLCh produces more perceptually uniform results than HSL
  • The 5 pipeline steps: hex → OKLCh → 19 levels → neutrals → dark mode → hex
  • Why the generated hex may differ slightly from the declared hex (and why this is correct)
  • How dark mode is generated mathematically by index inversion
  • What txtOn represents and why it changes depending on the palette level

Next step

N2-03 · The Config-First paradigm

You understand how colors are generated. The next step is to understand the paradigm that changes where design decisions are made — and why Figma, in this system, is a consumer, not the author.


References