In most projects a color scale comes into being like this: you take the brand color, drag a few lighter and a few darker variants out of the color picker and write ten hex values into your tokens. The result looks fine at first glance and falls apart in the third month. One step reads as muddy, two steps in the middle are barely distinguishable, and the same hover effect that works on the blue disappears on the yellow. The reason is not a lack of instinct, it is the format: hex and HSL describe how a screen produces a color, not how a person perceives it. OKLCH describes the second. This guide shows what the three axes mean, how they add up to a scale you no longer have to correct by hand, and where color-mix takes over work you would otherwise have spent another token on.
- The L in OKLCH is perceived lightness. Two colors sharing an L read as equally bright, whatever the hue. The L in HSL cannot do that: yellow and blue both sit at 50 percent there and look nothing alike.
- A scale is a list of lightness values. The hue stays fixed, the lightness runs through in even steps, and chroma follows an arc with its highest value in the middle. A constant chroma turns the light steps grey and the dark ones flat.
- color-mix and oklch have been available in all major browsers since 2023 and need no fallback. Mix in oklab, otherwise the path between two colors runs through a dull middle.
- Equal lightness distance is not equal contrast. L picks a step, but 4.5 to 1 is still decided by a contrast figure, and that one computes in sRGB.
Why does a hex value tell you nothing about its brightness?
A hex value is an instruction to the screen. Three numbers say how hard the red, the green and the blue light source should work. There is no statement in there about how bright the result looks, and you notice that immediately in a comparison: yellow as ffff00 and blue as 0000ff are both turned fully up on two channels. The yellow is dazzling, the blue is close to dark. Same format, same effort for the screen, two completely different impressions.
HSL promises improvement and only half delivers. The L there is called lightness and still is not perceived brightness, it is a geometric position inside a cylinder. hsl(60 100% 50%) is that glaring yellow, hsl(240 100% 50%) that dark blue, and both sit at 50 percent. So if you build a scale by taking even steps along L in HSL, you get uneven jumps: two steps in the middle land too close together, at the light end the color tips into grey, at the dark end it loses its character.
OKLCH changes the point of reference. It describes a color not through the work of the screen but through a model of perception. The first axis is the impression of brightness, and it holds across all hues: two values sharing an L read as equally bright, whether yellow, blue or red. That makes brightness a quantity you can compute with, and that is exactly what a scale needs.
Hex stores values. OKLCH describes impressions. Only the second one can be computed with.
What do L, C and H describe?
L is lightness and runs from 0 to 1, written as a percentage from 0 to 100. Zero is black, one is white, and the values in between are spread so that equal distances look equal. A jump from 0.40 to 0.50 reads as large as the one from 0.70 to 0.80. That is the single property this format is about.
C is chroma, meaning the distance from grey. At 0 all that remains is a shade of grey, and that holds whatever the hue. Upward there is no fixed limit: chroma is not a share that ends at 100 percent, it is an open distance. In practice the upper edge sits around 0.4, and if you use the percentage notation, 100 percent corresponds to exactly that value. How far you actually get depends on L and H and on the screen.
H is the hue in degrees. Here is where people stumble when moving over: the circle is turned differently than in HSL. Magenta starts at 0, red sits at about 41 degrees, and a degree value from an HSL declaration therefore cannot be carried across. If you are converting an existing color, compute it rather than copy it, and you do that once, after which the value stands.
--gelb: oklch(0.72 0.15 95); --blau: oklch(0.72 0.15 255); --rot: oklch(0.72 0.15 25); /* Prozentschreibweise, derselbe Wert */--gelb-prozent: oklch(72% 37.5% 95); /* Chroma auf null: drei identische Grautöne */--grau: oklch(0.72 0 95);
How does one color become a scale?
The starting point is the one color that is already fixed, the brand color. Convert it to OKLCH and read off the three values. The hue from it is set from now on and travels unchanged through every step. That already answers the question of what a scale actually is: a list of lightness values at a fixed hue.
You spread those lightness values at even distances, and because L describes perceived brightness, the distances then also look even. Ten steps from 0.97 down to 0.22 are a proven frame: at the top the barely tinted surfaces, in the middle the actual brand color, at the bottom what has to carry text on a light surface. The base belongs in the middle, not at an edge, otherwise one side runs out of room for states.
Chroma does not stay constant through this, and that is the part most people miss on the first attempt. Close to white and close to black there is hardly any room for colorfulness, in the middle there is the most. A constant value across all steps makes the light tones muddy and the dark ones flat. So chroma follows an arc: small at the top, highest around the base, smaller again towards the bottom. A slight rotation of the hue across the scale is possible and sometimes intended, but it is a decision, not a necessity. Start without it.
@theme { --color-brand-50: oklch(0.97 0.013 62); --color-brand-100: oklch(0.93 0.032 62); --color-brand-200: oklch(0.88 0.062 62); --color-brand-300: oklch(0.81 0.095 62); --color-brand-400: oklch(0.74 0.125 62); --color-brand-500: oklch(0.67 0.148 62); --color-brand-600: oklch(0.58 0.13 62); --color-brand-700: oklch(0.49 0.112 62); --color-brand-800: oklch(0.38 0.082 62); --color-brand-900: oklch(0.28 0.055 62);}
- Taking even steps along L in HSL. The jumps read as uneven, because that L does not describe brightness but a position in a cylinder.
- Keeping chroma identical across all steps. At the top the tone turns grey and muddy, at the bottom it loses its character.
- Making the brand color the lightest or darkest step. Then one side has no room left for hover, focus and active.
- Hunting for every step individually in the picker until it pleases. With ten steps the result is never consistent, and with two ramps never comparable.
- Fixing the hue once and leaving it unchanged across all steps. That makes the ten values visibly belong together.
- Spreading lightness at even distances, say from 0.97 down to 0.28. Equal distances then also read as equal.
- Letting chroma follow an arc, with its highest value around the base.
- Putting the base in the middle, so four steps remain above and below for states and surfaces.
A scale is a list of lightness values. Hue and chroma follow from it.
Few roles beat many values.
A list of hex numbers is an inventory. It becomes a system only once every value has a job.
Where is color-mix enough?
Not every color in a project has to sit in the scale. A border a touch lighter than the surface, a hover a touch darker, a backing for a selection: those are derivations, and derivations are what color-mix is for. The function takes two colors, a percentage and the space to mix in. One percentage is enough, the browser takes the remainder for the second color.
The space is no side issue. In srgb the path between two strong colors runs through a dull middle, because the channels there work against each other. In oklab the mixture stays alive over the whole way, and lightness behaves the way you would expect from a mixing ratio. So write in oklab unless you deliberately want something else.
For states there is a second route, and it is more precise: relative color syntax. oklch(from var(--color-brand-500) calc(l - 0.08) c h) takes the base apart, lowers only the lightness and leaves chroma and hue untouched. Note that the channels are present as numbers there: calc(l - 0.08) is valid, calc(l - 8%) is not. This syntax is younger than color-mix, available from Chrome and Edge 119, Firefox 128 and Safari 16.4, where the early Safari versions followed an older draft. An @supports block queries it cleanly.
/* Fläche aufhellen, gegen die eigene Oberfläche */ --surface-raised: color-mix(in oklab, var(--color-surface) 92%, var(--color-brand-500)); /* Rand aus der Textfarbe, ohne eigenen Wert */ --border-soft: color-mix(in oklab, currentColor 14%, transparent); /* Hover: nur die Helligkeit eine Stufe tiefer */@supports (color: oklch(from white l c h)) { .knopf:hover { background: oklch(from var(--color-brand-500) calc(l - 0.08) c h); }}
Which colors belong in the tokens?
Ten steps per ramp are a palette, not a system. If you write bg-brand-600 in your markup, you have written a number into your layout and will have to hunt for it everywhere at the next theme switch. So a layer of roles belongs between ramp and markup, and it is short: what is surface, what is text, what is border, what is accent.
A role points at a step. When the theme switches, only which step it points at changes, the ramp itself stays as it is. That is the difference between a set of colors you can maintain and one you resort every six months.
- The ramp keeps neutral names. brand-50 through brand-900 describe lightness values, not uses. They sit in the @theme block once and are not touched again afterwards, not even on a theme switch.
- The role says what for. surface, surface-muted, text, text-muted, border, accent: four to six names carry surprisingly far. Each points at exactly one step of the ramp or at a neutral grey step.
- States are derived, not listed. Hover, focus and active are not tokens of their own, they are a computation on the accent. With color-mix or relative syntax they come into being right where they are needed.
- Exceptions get a name of their own. The one color that falls outside the system, say a signal red for a warning, should not be forced into the ramp. A token of its own with a speaking name is more honest than a step that fits nowhere.
In Tailwind from version 4 the second part practically collapses into one: an entry --color-surface in @theme produces the utility bg-surface and leaves the same variable behind as a native custom property. That way markup and stylesheet read alike, and a glance at the markup reveals the intent rather than a number.
- primaryPrimary#1f5eff
- surfaceSurface#f8fafc
- textText#0f172a
- borderBorder#e2e8f0
On the left many values that barely differ. On the right the roles that remain of them.
What do you check before it goes live?
A computed scale is even, and even is not the same as checked. Three things are worth a pass of their own, and none of them announces itself.
- Contrast computes elsewhere. The WCAG figure rests on relative luminance in sRGB, not on the L from OKLCH. Equal L distances can therefore land on different sides of the 4.5 to 1 line. Pick the step with L and check the pair afterwards with a contrast tool.
- Too strong exists. If a chroma sits outside the displayable range, the browser looks for the nearest color: lightness and hue stay, chroma drops. On a screen with a wider gamut that happens less. So never rely on 0.02 of chroma to tell two states apart.
- The dark theme needs less colorfulness. The same hue on a dark surface reads as stronger, often to the point of glowing. Mirror the lightness, keep the hue and pull chroma back a little. A ramp that works well in light is rarely right in dark without changes.
What needs no checking is the availability of the two most important parts: oklch and color-mix have been present in all major browsers since 2023 and count as settled. A fallback in hex there is pure maintenance with nothing in return. Only relative syntax with from is younger, and it is the one part an @supports block may wrap if you still serve older versions of Safari.
That leaves the most uncomfortable question at the end, and it has nothing to do with the format: does the project really need ten steps per ramp? In most interfaces four of them are never used. A ramp of six steps that are all in use is easier to maintain than one of ten where nobody remembers what the 200 was meant for.
L is how you choose. Whether it passes is decided by a contrast figure.




