BlogWeb Development

Chart colors that follow any brand accent, with OKLCH

Derive every chart color from the customer’s accent with CSS relative colors in OKLCH: one line per hue, readable labels and a dark mode band.

by Karin Huber11 min read

If your product lets every customer set an accent color, your charts have a problem. A fixed categorical palette looks fine with your own brand and clashes with the next customer’s. Sooner or later one palette color sits right next to the accent with almost the same hue, or a yellow bar carries a white label nobody can read.

We ran into exactly this in time cockpit, where each tenant brings its own accent color. The fix turned out to be small: every chart color is derived from the accent at runtime, with one line of CSS per category.

Why rotating hues in HSL is not enough

The obvious idea is to take the accent and rotate its hue: hsl(from var(--accent) calc(h + 120) s l). The hues change, but the perceived brightness does not stay the same. HSL lightness is a property of the RGB cube, not of the human eye: at the same HSL lightness, a yellow green looks much brighter than a blue violet. In the example below, the rotation starts from the time cockpit blue, on which a dark label reaches 6.2 : 1. On the yellow green (−120°) it reaches 10.9 : 1, on the blue violet (+60°) only 2.4 : 1, well below the WCAG minimum of 4.5 : 1. The darker blue with a white label (second row) is no better: the range goes from 3.1 to 13.0 : 1.

HSL · S 71% · L 50% · dark label

Label 0° 6.2 : 1
Label +120° 4.1 : 1 (below 4.5 : 1)
Label −120° 10.9 : 1
Label +60° 2.4 : 1 (below 4.5 : 1)
Label +180° 4.9 : 1
Label −60° 9.8 : 1

HSL · S 100% · L 31% · white label

Label 0° 5.4 : 1
Label +120° 7.7 : 1
Label −120° 3.1 : 1 (below 4.5 : 1)
Label +60° 13.0 : 1
Label +180° 7.4 : 1
Label −60° 3.5 : 1 (below 4.5 : 1)
Hue turned in HSL, saturation and lightness unchanged. Top: from the time cockpit blue #25a0da with a dark label (#10161c); bottom: from the darker blue #00729f with a white label. Below each swatch: the contrast of the label.

OKLCH fixes this. Its three channels are lightness (L), chroma (C) and hue (h), and L is designed to match perceived lightness. If you keep L and C fixed and only change h, all colors look about equally bright and equally saturated. No category looks more important than another, and the same label color works on all of them, with one caveat we will come to. With the same start color, every swatch now lies between 5.6 and 6.4 : 1.

OKLCH · L 0.67 · C 0.13 · dark label

Label 0° 6.2 : 1
Label +120° 5.6 : 1
Label −120° 6.2 : 1
Label +60° 5.7 : 1
Label +180° 5.8 : 1
Label −60° 6.4 : 1

OKLCH · L 0.52 · C 0.135 · white label

Label 0° 5.4 : 1
Label +120° 5.9 : 1
Label −120° 5.4 : 1
Label +60° 5.8 : 1
Label +180° 5.8 : 1
Label −60° 5.2 : 1
The same start colors, hue turned in OKLCH. Top: with the lightness and chroma of the time cockpit blue and a dark label; bottom: with the fixed lightness of the pattern and a white label. Below each swatch: the contrast of the label.

The swatches are even now, but the blue is too light for white text. The pattern therefore takes only the hue from the accent and sets lightness and chroma to fixed values, L 0.52 and C 0.135 (second row). White labels reach 5.2 to 5.9 : 1 here. Because L no longer comes from the accent, readability no longer depends on how light a customer’s brand color is: on paper, every hue stays at 5.1 : 1 or more. Why it is L 0.52 in particular, and what the browser actually renders, is the subject of trap 2 below.

The price: less vivid colors

Next to the second HSL row, the OKLCH palette looks duller. That is not OKLCH’s fault but the result of choosing equal lightness. HSL at S 100% pushes every color to the edge of what sRGB can show, at whatever lightness that hue happens to have in HSL. At a fixed lightness, that edge lies at a different distance for every hue:

HueOffsetHighest C in sRGB at L 0.52C in the patternHSL row: LHSL row: C
Blue0°0.1110.1110.520.110
Pink+120°0.2130.1350.470.199
Yellow green−120°0.1180.1180.650.167
Violet+60°0.2850.1350.340.209
Orange+180°0.1280.1280.470.157
Teal−60°0.0980.0980.610.190

The “C in the pattern” column shows what C 0.135 actually turns into. At L 0.52, blue, yellow green, orange and teal hit the edge of sRGB before that and lose chroma until they can be shown. So the palette is not quite equally saturated. Truly equal chroma for any accent would only be possible up to about C 0.09 at L 0.52, and that would look much duller. C 0.135 is a compromise. Pink and violet, on the other hand, could be 1.6 and 2.1 times as saturated.

The HSL row benefits from its differences in lightness. Its yellow green sits at L 0.65, its teal at L 0.61, and sRGB allows more chroma there. Some hues are only vivid when they are light: yellow green is most saturated at L 0.95, teal at L 0.89. At L 0.52, yellow inevitably turns into olive. But these very differences in lightness are what make the labels unreadable in HSL. On top of that, equal offsets in HSL are not equal offsets in OKLCH. The HSL “teal” at −60° has a hue of 145° in OKLCH and is really a green.

If you want more vivid colors, you can keep the same lightness for every hue but scale its chroma to its own maximum instead of a shared value, much like the saturation in OKHSL. The bottom row uses the full maximum for every hue. Lightness stays the same, and white labels still reach at least 5.2 : 1. Only pink and violet change, and they do not just glow, they also push themselves forward:

OKLCH · L 0.52 · C 0.135 · white label

Label 0° 5.4 : 1
Label +120° 5.9 : 1
Label −120° 5.4 : 1
Label +60° 5.8 : 1
Label +180° 5.8 : 1
Label −60° 5.2 : 1

OKLCH · L 0.52 · highest C per hue · white label

Label 0° 5.4 : 1
Label +120° 6.2 : 1
Label −120° 5.4 : 1
Label +60° 6.5 : 1
Label +180° 5.8 : 1
Label −60° 5.2 : 1
Top: the light band with fixed chroma; bottom: the same hues with the highest chroma sRGB allows at L 0.52. Only pink and violet change; the other four are already at the edge of sRGB in the top row. Below each swatch: the contrast of the label.

You cannot do this with one line of CSS, though, because the relative color syntax does not know the maximum of a hue. The values have to be computed in JavaScript. You could also set C deliberately too high and rely on the browser to lower the chroma to the edge. But Chromium clips the RGB channels instead and shifts hue and lightness in the process (see trap 2).

On displays with a P3 gamut, such as current Macs and phones, there is more room. For the time cockpit blue, five of the six hues fit at C 0.135 and teal almost does (C 0.133), and the browser shows them without compromise. So the same palette looks slightly different on P3 and sRGB displays. The examples here are sRGB values and do not show this.

Try it yourself: HSL and OKLCH side by side

Pick a start color, for example your own brand color, and change its saturation and lightness with the sliders. The third slider sets how many colors are generated, from 4 to 12, and you can choose the text color on the swatches yourself. Unlike the pattern, the hues here are spread evenly and stand side by side in the order in which they follow each other on the hue circle. That way you can see how similar neighbors become as the number of colors grows. The OKLCH rows here also keep the lightness of the start color. At almost every setting the contrast values of the OKLCH row stay closer together than those of the HSL row. The third row gives every hue the same share of its highest possible chroma as the start color has of its own.

Text color

Turned in HSL · S 71% · L 50% dark label · contrast 2.4 – 10.9 : 1

Aa 0° 6.2
Aa +60° 2.4 (below 4.5 : 1)
Aa +120° 4.1 (below 4.5 : 1)
Aa +180° 4.9
Aa −120° 10.9
Aa −60° 9.8

Turned in OKLCH · L 0.67 · C 0.132 dark label · contrast 5.6 – 6.4 : 1

Aa 0° 6.2
Aa +60° 5.7
Aa +120° 5.6
Aa +180° 5.8
Aa −120° 6.2
Aa −60° 6.4

OKLCH, relative chroma · L 0.67 · C 93% of the maximum dark label · contrast 5.4 – 6.4 : 1

Aa 0° 6.2
Aa +60° 5.6
Aa +120° 5.4
Aa +180° 5.8
Aa −120° 6.2
Aa −60° 6.4
The color picker sets the start color, the sliders change its saturation and lightness in HSL and the number of colors. The hues are spread evenly around the hue circle and stand side by side in the order in which they follow each other there. Top: the hue turned in HSL; middle: turned in OKLCH with the lightness and chroma of the start color; bottom: every hue gets the same share of its highest possible chroma as the start color. The text color can be chosen; on automatic, each row gets the one, white or dark, that makes its weakest swatch more readable. Below each swatch is its contrast to the label. Colors outside sRGB lose chroma until they fit.

The pattern: one custom property per category

CSS relative color syntax lets you take an existing color apart and build a new one from its channels. With oklch(from …) you read the accent’s hue and replace lightness and chroma with fixed values:

:host {
  --app-hue-0: oklch(from var(--accent-base) 0.52 0.135 h);
  --app-hue-1: oklch(from var(--accent-base) 0.52 0.135 calc(h + 120));
  --app-hue-2: oklch(from var(--accent-base) 0.52 0.135 calc(h - 120));
  --app-hue-3: oklch(from var(--accent-base) 0.52 0.135 calc(h + 60));
  --app-hue-4: oklch(from var(--accent-base) 0.52 0.135 calc(h + 180));
  --app-hue-5: oklch(from var(--accent-base) 0.52 0.135 calc(h - 60));
  --app-ink: #fff;
}

Three decisions are in these six lines:

  • The first category is the accent’s own hue. The customer’s brand shows up in the chart instead of competing with it.
  • Neighbors are far apart. Our bars are sorted by size, so category 0 sits next to 1, 1 next to 2 and so on. The order accent, +120°, −120°, +60°, +180°, −60° puts at least 120° between any two neighbors. The first three hues split the circle into thirds, the last three fill the gaps.
  • Lightness and chroma are constants. L 0.52 is dark enough for white labels, even when the browser clips the colors (trap 2), C 0.135 is saturated enough to tell the hues apart without screaming.

Related categories can also be paired on purpose. For the AI cost bars in time cockpit we split cost into four token kinds: input, output, cache written and cache read. Output gets the accent’s hue and input sits 45° after it. The two cache kinds are the same pair turned by −120°. Input and output read as one family, the caches as another, and still every kind has its own color.

Dark mode: a second band, dark ink

On a dark background, the same colors at L 0.52 look muddy. Dark mode gets its own band with the same hue offsets: higher lightness, slightly less chroma, and dark text on the bars instead of white.

:host-context(html.dark-mode) {
  --app-hue-0: oklch(from var(--accent-base) 0.76 0.115 h);
  --app-hue-1: oklch(from var(--accent-base) 0.76 0.115 calc(h + 120));
  /* … the same offsets as in the light band … */
  --app-ink: #10161c;
}

The categories stay recognizable: hue 1 in dark mode is the same hue as hue 1 in light mode, only lighter. The example shows both bands on a dark background:

Light band (L 0.52) · white label

Label 0° 5.4 : 1
Label +120° 5.9 : 1
Label −120° 5.4 : 1
Label +60° 5.8 : 1
Label +180° 5.8 : 1
Label −60° 5.2 : 1

Dark band (L 0.76 · C 0.115) · dark label

Label 0° 8.6 : 1
Label +120° 8.1 : 1
Label −120° 8.7 : 1
Label +60° 8.2 : 1
Label +180° 8.2 : 1
Label −60° 8.9 : 1
Both bands on a dark background. The light band stays readable but looks dark and muddy; the dark band has the same hues at a higher lightness. Below each swatch: the contrast of the label.

Three traps, with numbers

The pattern is short, but it is easy to believe it does more than it does. We built a small HTML workbench before touching the product code: an accent picker, sliders for the hue offsets and for L and C, a WCAG contrast table for every color, and the OKLab distance between neighboring colors. These are the three things it taught us.

Trap 1: equal OKLCH lightness is not equal WCAG contrast

OKLCH lightness and WCAG contrast measure different things. WCAG 2 contrast is based on relative luminance computed from sRGB, and the eye model behind OKLab is not the same curve. At the same L, a green and a violet still end up with different contrast against white.

For six hues at L 0.52 and C 0.135 with white labels, we computed these ranges:

AccentWhite labels, light bandThe same, channels clipped as in ChromiumHues outside sRGB (light band)Dark labels (#10161c), dark band
#25a0da5.2 – 5.9 : 14.9 – 5.9 : 14 of 68.1 – 8.9 : 1
#9b2fae5.2 – 5.9 : 14.8 – 5.9 : 12 of 68.1 – 8.9 : 1
#c8102e5.2 – 5.9 : 14.8 – 5.9 : 12 of 68.1 – 8.8 : 1
#2e8b575.2 – 6.0 : 14.9 – 6.0 : 13 of 68.1 – 8.9 : 1

So at the same lightness, the hues end up as much as a full contrast point apart, about 4.8 versus 5.9 : 1 when clipped. Choose L for the worst case, not for the average: every combination must stay at or above 4.5 : 1, the WCAG minimum for normal text.

Trap 2: some hues do not exist in sRGB

At chroma 0.135, two to four of the six hues fall outside the sRGB gamut for typical accents, four for the time cockpit blue. The browser does not fail, but it does not necessarily do what you expect. CSS Color 4 describes a gamut mapping that keeps lightness and hue and only lowers chroma. In our measurement, Chromium clips each RGB channel separately instead. That shifts hue and lightness as well: the blue moves from 235° to 241°, the teal from 175° to 171° and from L 0.52 to L 0.537. The chart still works, but those colors are not quite the ones in your CSS, and their contrast shifts. That is exactly why the pattern uses L 0.52. With our first value, L 0.54, the clipped teal dropped to 4.49 : 1, just below the WCAG threshold. Computed across all accents, the worst case even dropped to 4.4 : 1, for hues between 174° and 207°. At L 0.52, every hue stays at 4.8 : 1 or more even when clipped. Measure the rendered result instead of trusting the formula, and lower L or C until the worst case passes.

Trap 3: an invert filter distorts your palette

The time cockpit web app implements dark mode by inverting the whole page with filter: invert(1) hue-rotate(180deg). That is cheap and works surprisingly well for text and borders. For a balanced palette it is not: the filter inverts the lightness and roughly keeps the hue, but it computes in sRGB, not in OKLCH. Six colors with the same lightness L 0.52 turn into colors between L 0.69 and L 0.76, the olive clearly darker than the pink. The even palette is gone, and the dark band you designed for dark mode never shows up.

Light band as designed

Label 0° L 0.52
Label +120° L 0.52
Label −120° L 0.52
Label +60° L 0.52
Label +180° L 0.52
Label −60° L 0.52

The same after invert(1) hue-rotate(180deg)

Label 0° L 0.73
Label +120° L 0.76
Label −120° L 0.69
Label +60° L 0.72
Label +180° L 0.74
Label −60° L 0.72

Dark band as designed

Label 0° L 0.76
Label +120° L 0.76
Label −120° L 0.76
Label +60° L 0.76
Label +180° L 0.76
Label −60° L 0.76
Top: the light band; middle: the same after the dark mode’s invert filter, which inverts the lightness, turns white labels black and roughly keeps the hue; bottom: the dark band designed for dark mode. Below each swatch: its OKLCH lightness.

The chart elements are therefore taken out of the inversion. They carry a class that applies the same filter a second time, which largely cancels the first, and they use the dark band from above:

html.dark-mode {
  filter: invert(1) hue-rotate(180deg);
}

html.dark-mode .no-dark-invert {
  filter: invert(1) hue-rotate(180deg);
}

It is not exact, because the browser clips to the sRGB range after each filter pass. The teal of the dark band comes out as #57c4a9 instead of #51c9ad; the other five colors stay practically unchanged. If your dark mode is built with real color tokens instead of a filter, you do not have this problem. If it is built with a filter, check every colored element.

Fallbacks for older browsers

Relative color syntax is supported in all current evergreen browsers, but not in older ones. The usual trick of declaring a property twice and letting the browser drop the version it does not understand does not work here: custom properties accept any value at parse time, so the browser keeps the relative color and only fails when it resolves var(--app-hue-1). The bar then ends up without a background.

The fallback therefore needs an explicit feature query. We put fixed hex values for the default accent first and override them only where relative colors work. The query has to test exactly the syntax you use: Safari 16.4 to 17 already knows oklch(from …) but requires a unit on the hue and rejects calc(h + 120). A test for oklch(from red l c h) would pass there, and every bar except the first would end up without a background. So we include a calculation with h in the test:

:host {
  --app-hue-0: #00729f;
  --app-hue-1: #a2426c;
  /* … */

  @supports (color: oklch(from red l c calc(h + 1))) {
    --app-hue-0: oklch(from var(--accent-base) 0.52 0.135 h);
    --app-hue-1: oklch(from var(--accent-base) 0.52 0.135 calc(h + 120));
    /* … */
  }
}

Customers on an old browser see the default palette instead of their own. That is a fair trade.

Where we use it

In time cockpit, the time sheet calendar (Angular) and the desktop signal tracker (Electron) draw AI cost bars split by token kind and an application bar split by application. All of these colors are derived from the tenant’s accent, in light and dark mode. The six largest applications get the six hues; everything else is gray.

When not to do this

  • More than about six categories. With fixed L and C, hue is the only thing left to tell categories apart. With six colors, neighbors are 60° apart on the hue circle; with twelve, only 30°. Around blue and teal, where sRGB allows little chroma, they move even closer together. Give the long tail a neutral gray.
  • When color alone identifies the category. Equal lightness means that in grayscale all categories look almost the same, and people with a color vision deficiency lose the lightness difference that would otherwise help them. In a simulation of deuteranopia (green blindness, a form of the most common color vision deficiency), pink and teal as well as yellow green and orange become practically indistinguishable in our palette. Neighboring segments also have only about 1.1 : 1 contrast with each other. WCAG 1.4.11 requires 3 : 1 against adjacent colors for graphics needed to understand the content. So separate segments with a narrow gap in the background color (every hue has at least 4.8 : 1 against white) and label them directly instead of relying on a legend alone.
  • Colors with a fixed meaning. If red means error and green means done, those colors must not rotate with the brand. Keep semantic colors out of the derived palette. It also works the other way round: with a green brand color, the very first category is green and easily reads as “done”.
  • When contrast is not checked. The pattern makes the palette even, not automatically accessible. But you do not have to check every accent separately: an accent only shifts the six hues around the hue circle. Check all 360 hues once at your L and C, the way the browser actually renders them. The result then holds for every accent.
  • Accents without a hue. White, gray and black have no chroma and therefore no meaningful hue. Chromium returns about 24° for #808080 and for white, so the whole palette starts at a reddish brown that has nothing to do with the brand. Catch such accents before they reach the CSS and use a fixed default hue instead.

Takeaway

Fix OKLCH lightness and chroma, rotate only the hue relative to the accent, keep neighbors at least 120° apart, and give dark mode its own band. Then verify contrast and gamut with real numbers instead of trusting the color space.

If you have questions about theming or design systems in your own web application, get in touch.