The moment a color palette enters a design system, it stops being a collection of colors that look good together. It becomes a scale, and a scale needs a rule.
That may sound like a theoretical distinction, but it shows up in the code almost immediately. A designer hands over a set of shades, a developer turns them into tokens, and then the state dance begins: default, hover, active, selected. The same decisions are repeated for Buttons, Badges, Alerts, Toasts, Tags, and every other component that uses that color.
If each shade has been picked individually, relying on the eye alone, every transition requires another decision. If the shades share a fixed hue and chroma, or hue and saturation, and move through a consistent lightness step, states become predictable positions on the same scale.
That is the difference between a palette that was drawn and a palette that was designed.
The rule
For each color family, we can define three coordinates:
- a fixed hue;
- a fixed saturation, or chroma;
- a lightness value that changes by a consistent interval.
In its simplest form:
shade(n) = color(Lbase + n × step, C, H)
H identifies the color family, C controls its intensity, and
L identifies the shade's position on the scale. step is the lightness interval
shared by every transition.
The same principle can be applied with HSL by keeping hue and saturation fixed and changing lightness. The result is numerically regular, but not necessarily perceptually uniform: two HSL steps of the same size can look very different depending on the hue.
This is why, when I can choose, I prefer OKLCH. Its L coordinate is designed to describe
perceived lightness more reliably, C represents chroma, and H preserves the hue.
LCH follows the same conceptual model, while OKLCH generally behaves better when building palettes for
interfaces.
Throughout this article, I will use "lightness step" in the practical sense commonly used between design
and development. Technically, the L channel in OKLCH represents perceptual lightness, not the
relative luminance used to calculate WCAG contrast. They are related measurements, but they are not
interchangeable.
The formula remains understandable even to someone who does not write CSS:
oklch(lightness chroma hue)
For example:
oklch(62% 0.18 255deg)
We have a lightness of 62%, a chroma of 0.18, and a hue of 255deg.
If we decide that each state should be 6% away from the previous one, we already know how to
obtain the following variants.
A palette without a progression
Let us start with a plausible palette, built by selecting colors individually because they seem to work well next to one another:
| Shade | Value | ΔL | ΔC | ΔH |
|---|---|---|---|---|
| 400 | oklch(72% 0.14 248deg) |
|||
| 500 | oklch(63% 0.19 257deg) |
-9% |
+0.05 |
+9deg |
| 600 | oklch(55% 0.17 253deg) |
-8% |
-0.02 |
-4deg |
| 700 | oklch(43% 0.15 261deg) |
-12% |
-0.02 |
+8deg |
These are not bad colors. The problem is that their relationship cannot be described by a stable rule.
Lightness, chroma, and hue all change at the same time, while the lightness step varies between
8% and 12%.
The code has to store every result:
@layer tokens {
:root {
--brand-400: oklch(72% 0.14 248deg);
--brand-500: oklch(63% 0.19 257deg);
--brand-600: oklch(55% 0.17 253deg);
--brand-700: oklch(43% 0.15 261deg);
--button-background-default: var(--brand-500);
--button-background-selected: var(--brand-600);
--button-background-hover: oklch(51% 0.16 255deg);
--button-background-active: var(--brand-700);
--badge-background-default: var(--brand-500);
--badge-background-selected: oklch(57% 0.18 254deg);
--badge-background-hover: var(--brand-600);
--badge-background-active: var(--brand-700);
--tag-background-default: var(--brand-500);
--tag-background-selected: var(--brand-600);
--tag-background-hover: oklch(50% 0.16 257deg);
--tag-background-active: var(--brand-700);
}
}
@layer components {
button[data-variant='brand'] {
background: var(--button-background-default);
&[aria-pressed='true'] {
background: var(--button-background-selected);
}
&:hover {
background: var(--button-background-hover);
}
&:active {
background: var(--button-background-active);
}
}
output[data-badge='brand'] {
background: var(--badge-background-default);
&[data-selected] {
background: var(--badge-background-selected);
}
&:hover {
background: var(--badge-background-hover);
}
&:active {
background: var(--badge-background-active);
}
}
a[data-tag='brand'] {
background: var(--tag-background-default);
&[aria-current='true'] {
background: var(--tag-background-selected);
}
&:hover {
background: var(--tag-background-hover);
}
&:active {
background: var(--tag-background-active);
}
}
}
This example contains only three components and already needs twelve state-specific tokens. Some point to an existing shade, while others contain local corrections because the available transition feels either too subtle or too strong.
Those corrections look harmless. Six months later, nobody remembers why the Button hover uses
51%, the Tag uses 50%, and the Badge happens to match shade 600. All three are
blue and look almost identical, but they no longer share the same behavior.
The real cost appears when the palette changes. We are not updating a scale. We are hunting down every decision that the scale scattered throughout the components.
A palette with a consistent step
Now let us design the same family with a fixed hue, a fixed chroma, and a 6% lightness step:
| Shade | Value | ΔL | ΔC | ΔH |
|---|---|---|---|---|
| 400 | oklch(68% 0.18 255deg) |
|||
| 500 | oklch(62% 0.18 255deg) |
-6% |
0 |
0deg |
| 600 | oklch(56% 0.18 255deg) |
-6% |
0 |
0deg |
| 700 | oklch(50% 0.18 255deg) |
-6% |
0 |
0deg |
The palette can now be described with four pieces of information: starting lightness, step, chroma, and hue. We no longer need to store every derived color.
@layer tokens {
:root {
--brand-lightness: 62%;
--brand-chroma: 0.18;
--brand-hue: 255deg;
--color-step: 6%;
}
}
The component does not need to know about shade 500, 600, or 700. It only needs to declare its current state:
@layer components {
:where(button[data-variant='brand'], output[data-badge='brand'], a[data-tag='brand']) {
--selection-step: 0%;
--interaction-step: 0%;
--state-lightness: clamp(0%, calc(var(--brand-lightness) - var(--selection-step) - var(--interaction-step)), 100%);
background: oklch(var(--state-lightness) var(--brand-chroma) var(--brand-hue));
&:is([aria-pressed='true'], [aria-current='true'], [data-selected]) {
--selection-step: var(--color-step);
}
&:hover {
--interaction-step: var(--color-step);
}
&:active {
--interaction-step: calc(var(--color-step) + var(--color-step));
}
}
}
Default means zero steps. Selected and hover mean one step. Active means two steps. When selected and hover coexist, their offsets add up without introducing a special color chosen for that single component.
Buttons, Badges, and Tags now apply the same color grammar. Alerts and Toasts can use the same formula for their border, icon, or surface by choosing a different starting position on the scale without changing the distance between states.
@layer components {
:where([role='alert'], output[data-toast]) {
--surface-start: 94%;
--surface-step: var(--color-step);
--surface-chroma: 0.06;
border-color: oklch(
calc(var(--surface-start) - var(--surface-step) - var(--surface-step)) var(--brand-chroma) var(--brand-hue)
);
background: oklch(var(--surface-start) var(--surface-chroma) var(--brand-hue));
}
}
Here, chroma is reduced for a large, light surface. We are not breaking the rule by accident. We are declaring a second series with a different purpose. Within that series, hue and chroma remain stable and lightness continues to move along the same grid.
An honest comparison
A mathematical palette does not automatically remove every token. Semantic tokens are still useful for expressing intent, supporting themes, and separating product decisions from their color representation.
The difference is that we are no longer forced to use a token to store every intermediate result.
| Aspect | Irregular palette | Consistent progression |
|---|---|---|
| Source of truth | Every individual shade | Base coordinates and step size |
| States | Colors or aliases defined one by one | Offsets calculated from the base |
| Consistency across components | Depends on each implementation | Built into the shared formula |
| Direction changes | Require reviewing distributed values | Change the base, the step, or both |
| Required tokens | Grow with shades, states, and corrections | Grow mainly with semantic meanings |
| Verification | Visual comparison across many values | Mathematical rule plus visual review |
The irregular example uses four primitive tokens and twelve state tokens for three components. The regular example uses four color parameters and one formula shared by all three components.
In a real product, this does not mean that sixteen tokens will always become four. It means preventing the number of tokens from growing as the product of colors, shades, components, and states. The remaining tokens represent actual decisions rather than values the browser could calculate on its own.
Design and development must share the same rule
This approach works only when the progression starts in design. Receiving an irregular palette and regularizing it during implementation is not enough, because Figma and the product would already be describing two different systems.
The design file should make at least these properties visible for each color family:
- the color space being used;
- the hue and chroma of the series;
- the starting and ending lightness;
- the size of each step;
- the relationship between positions on the scale and component states.
The handoff is no longer "these are the blues." It becomes "this family uses hue 255, chroma 0.18, and lightness steps of 6%."
It looks like a small detail, but it allows design and development to discuss the same thing. If hover is too close to default, we can increase the step. If active is too dark, we can change the starting position or the number of steps. The decision is explicit, repeatable, and testable across every component.
Regular does not automatically mean accessible
A consistent progression does not guarantee sufficient contrast. The formula describes the relationship between shades. It does not decide whether text, icons, borders, and surfaces meet contrast requirements in the combinations where they are actually used.
Every meaningful state still needs to be checked against the color above or beside it. States also need to be distinguishable from one another, because a perfectly regular step may still be too small to communicate a change reliably.
Focus deserves a separate rule. It should not be communicated only by changing the background lightness. A
recognizable :focus-visible indicator, such as an outline with sufficient contrast, must
remain present and independent from the calculated color states.
The advantage is that when a test fails, the correction can happen at the system level. We can change the
step from 6% to 7%, move the starting lightness, or assign two steps instead of
one to a particular state. We do not have to chase a constellation of unrelated values.
Gamut also needs to be checked. Some lightness and chroma combinations produce colors that a display cannot represent exactly. A designed palette preserves its rule, validates each shade against the target gamut, and reduces chroma where necessary through an explicit decision.
The palette as an API
The lightness step is a contract. It tells the designer how far apart two shades should be. It tells the developer how to derive a state. It tells whoever maintains the design system what can change without introducing local exceptions.
The most interesting result is not saving a few custom properties. It is being able to implement Buttons, Badges, Alerts, Toasts, Tags, and other components using the same mental model: one color family, one starting position, and a number of steps associated with each state.
When a palette is a list, every new state adds a decision. When a palette is a function, every new state applies a rule.
And the code that is easiest to maintain is usually the code that stores the necessary decisions and calculates everything else.