Skip to content

Instantly share code, notes, and snippets.

@facelessuser
Last active October 7, 2026 18:23
Show Gist options
  • Select an option

  • Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.

Select an option

Save facelessuser/0235cb0fecc35c4e06a8195d5e18947b to your computer and use it in GitHub Desktop.
Exploring Tonal Palettes

Exploring Tonal Palettes

HCT

HCT is a color model developed by Google. It aims to solve a problem related to generating color palettes with good contrast. While HCT may seem like a revolutionary color model, the idea behind it is quite simple, take the perceptually uniform color model CAM16 and combine it with the CIE Lab's lightness.

Upside of HCT

When constructing HCT, Google chose CAM16 as it is more perceptually uniform than CIE Lab, but also chose CIE Lab's lightness as it is more ideal for contrast. Combining these two models creates the best of both worlds. With this new color model, you pick a color and just adjust the tone (lightness) and get palettes with much better contrast.

For similar results to Google, we need to take the HCT color model, generate colors with different tones and gamut map them in HCT fairly tight in sRGB.

def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]


colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(hct_tonal_palette(color))

It was determined that when using this model, that CIE Lab lightness could be the sole deciding factor to determine contrast between these colors.

Downside of HCT

CAM16 is an expensive color model to calculate. Combining two disparate color models means calculating back out of the space is now more difficult and requires more complex calculations to approximate back out of the color model. And while CAM16 is "perceptually accurate", there is no perfectly perceptual model. CAM16 still suffers from purple shifts in the blue region as an example.

What About OkLCh?

Some people may be interested in other solutions. CSS already makes Oklab/OkLCh available, it is much easier and far less expensive to calculate. It also has much better hue preservation in the blue region. But if we try it, we can see the lightness is not so desirable.

Scaling the tone pattern from [0, 100] down to [0, 1] (Oklab's lightness scaling) and gamut mapping the colors to sRGB tightly using OkLCh, we can see that the lightness poses an issue.

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', tone / 100).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(oklch_tonal_palette(color))

But Björn Ottosson, in his blog post about Okhsl and Okhsv provided an alternative lightness for Oklab/OkLCh. This alternative lightness was used to create Okhsl and Okhsv with a lightness response similar to what people expect with CIE Lab. Using a toe function to adjust the black level, he was able to better approximate CIE Lab lightness.

So what happens if we try to use this lightness to generate tonal maps in OkLCh? Let's find out!

To do so, we can use the existing OkLCh color space, but when setting the tone, we will approximate CIE Lab lightness by using the inverse toe to translate the tone values back to normal Oklab and OkLCh lightness.

K_1 = 0.206
K_2 = 0.03
K_3 = (1.0 + K_1) / (1.0 + K_2)

def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0) for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(oklch_tonal_palette(color))

The results seem to have pretty decent contrast, and our blue palette doesn't have a purple shift. But let's compare and see how OkLCh looks next to HCT.

K_1 = 0.206
K_2 = 0.03
K_3 = (1.0 + K_1) / (1.0 + K_2)

def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(hct_tonal_palette(color))
    Steps(oklch_tonal_palette(color))

The results are surprisingly similar, but there is still a noticeable difference in lighting in the dark region. Is it possible to tweak the toe to get a little closer to HCT results? Here we change the K_1 value to 0.173 and the K-2 value to 0.004. This changes achromatic lightness to more closely match CIE Lab and gives us results that appear closer to the HCT results.

K_1 = 0.173
K_2 = 0.004
K_3 = (1.0 + K_1) / (1.0 + K_2)

def toe_inv(x: float) -> float:
    """Inverse toe function for L_r."""

    return (x ** 2 + K_1 * x) / (K_3 * (x + K_2))

def oklch_tonal_palette(c):
    """OkLCh tonal palettes."""

    c = Color(c).convert('oklch')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('l', toe_inv(tone / 100)).fit('srgb', method='oklch-chroma', jnd=0.0).convert('srgb') for tone in tones]

def hct_tonal_palette(c):
    """HCT tonal palettes."""

    c = Color(c).convert('hct')
    tones = [0, 5, 10, 15, 20, 25, 30, 35, 40, 50, 60, 70, 80, 90, 95, 98, 99, 100]
    return [c.clone().set('tone', tone).fit('srgb', method='hct-chroma', jnd=0.0).convert('srgb') for tone in tones]

colors = ['gray', 'red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violet']

for color in colors:
    Steps(hct_tonal_palette(color))
    Steps(oklch_tonal_palette(color))

Conclusion

While HCT does make it easier to create palettes with decent contrast, there may be less computationally expensive approaches to get similar results.

@facelessuser

Copy link
Copy Markdown
Author

@hereticmilk Glad you stumbled on my random little experiment and found the discussion useful :).

@nealmckee

Copy link
Copy Markdown

The way that Oklab is defined, it’s actually not too hard to change the luminance to match CIELab L like the Google team did with CAM16. That’s what I’ve been using for a project recently – for me that has been the best solution for color palettes and gradients so far, especially due to the lack of hue shifts.

@facelessuser

Copy link
Copy Markdown
Author

I think the main point was not to say a similar thing couldn't be done with Oklab, but more that going through a convoluted method to fuse the exact CIELab L and Oklab chroma and hue into one new color space that is harder and more expensive to compute back out of is really not necessarily needed if you want to use Oklab for this sort of thing. If you are using Oklab, it is much easier and more efficient to apply a simple toe function to Oklab lightness that is trivial to undo, and it will give you decent approximation of the same thing, but yes, it won't be exactly CIELab lightness, just an OK approximation. If you require exact CIELab lightness, then you would have to use a different approach.

@nealmckee

nealmckee commented May 4, 2025 •

Copy link
Copy Markdown

It seems I misunderstood what Google was doing. What I was trying to say is that a simple gamma transform/toe function (including the linear portion of CIELab L importantly) is already sufficient to get basically the exact CIELab L in Oklab. You do not need to calculate the actual L in CIELab and glue them together. It’s very precise on the grey axis and has a very minor (at most about 0.5 L in CIELab from some rough initial tests) shear in the red/green axis, which is acceptable IMHO and still a major improvement over the approximation suggested by the original author.

@facelessuser

Copy link
Copy Markdown
Author

Ah, makes sense.

@WordlessEcho

WordlessEcho commented Sep 20, 2026 •

Copy link
Copy Markdown

Some opinions from James O'Leary (creator of Google HCT):
https://news.ycombinator.com/item?id=44522211

If they knew what they were talking about at the time, they wouldn't have been doing gradients in CAM16-UCS, and not done a lerp, but used the standard CSS gradient technique of "rotating" to the new point.

Because that's how you avoid gray.

@facelessuser

Copy link
Copy Markdown
Author

I'm not quite sure what the point of posting that here is. He does seem a bit salty about Oklab for some reason. Our goal here is not to say Oklab is better than HCT or that HCT is somehow superior to Oklab. Regardless, please keep comments relevant to what is being shown and discussed here. If you don't want to use Oklab for tonal palettes, then don't.

@WordlessEcho

Copy link
Copy Markdown

Sorry I just curious why purple shift exist. I noticed that he mentions about the gradient. I edited my previous comment and add more about purple shifts to foucs the key.

https://x.com/jpohhhh/status/1405309608013537294

the purple blue thing...i think the chroma cap might be messing with it, I get CAM16 hue 283 for #0000ff. top set of shades is with no chroma cap, bottom is with chroma cap (as svg, with annotated H C L values https://pastebin.com/pThJX015)

For this someone recommand ZCAM:

https://x.com/jpohhhh/status/1495458460875563012

For what its worth, avoided ZCAM because #1) was leery of science #2) "stuck with 100K collaborators" reasons, hard to change direction midflight without taking a hit on credibility, CAM16 chroma was intuitive in that it scales to around 100 in standard viewing conditions

@facelessuser

Copy link
Copy Markdown
Author

Honestly, I believe this is partly based on the fact that most color spaces use the 1931 CMFs (color matching functions). The CIE 2006 CFMs improve things in the blue region. I believe this corrects many issues in the blue region of the CMFs. Regardless of whether this is 100% the reason or not, I'm sure it contributes to some degree, but there is too much based on the 1931 CMFs that I don't expect a huge shift away from them anytime soon. Color spaces can certainly mathematically bend hues more to get around the purple shift, and there are spaces that do this: ZCAM, Jzabz, IPT, etc.

I'm not going to sit here and pretend I'm a color scientist, but regardless of whether CAM16 is more scientific than Oklab, CAM16 has visual purple shifts that no one can deny. Oklab, much like IPT, Jzazbz, ZCAM, etc. do not have this same kind of purple shift. I won't say Oklab is great for everything, but I'll say that it can create a better blue tonal palette (with some tweaking of lightness) than HCT 🤷.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment