Subpop

Why Is Everyone Green or Purple?

When I started building Relay, a native macOS Matrix client, I wanted a way to assign a unique color to each user. The idea was simple: hash the user's ID, derive a hue, and use it for their avatar background and message bubble. The same person always gets the same color, across app restarts, for all installations of Relay. This created a fun "what's your Relay color" meme among the early adopters. You can even see your own "Relay color" in your own outgoing messages (mine's purple, btw).

The algorithm was just twelve lines of Swift:

static func color(for name: String) -> Color {
    let hash = djb2Hash(name)
    let hue = CGFloat(hash % 360) / 360.0

    let nsColor = NSColor(name: nil) { appearance in
        if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
            NSColor(hue: hue, saturation: 0.5, brightness: 0.65, alpha: 1)
        } else {
            NSColor(hue: hue, saturation: 0.45, brightness: 0.75, alpha: 1)
        }
    }
    return Color(nsColor: nsColor)
}

Start by hashing the username (or any string, really) with a DJB2 hash, modulo 360, divide by 360 to get a hue fraction, plug it into HSB with fixed saturation and brightness. Clean, fast, deterministic. It worked! It's what is shipping in Relay today.

Before

That was over two months ago, but as I kept using Relay and developing it, I started to notice something. It felt like nearly everyone was either green or purple.

The nagging feeling

Now, it wasn't literally two colors. But scrolling through messages, the bubbles seemed to cluster into about two or three groups. Given the huge variety of Matrix IDs, why are so many colors nearly identical.

My first thought was to question the hash function. If the function was not creating an even distribution of hash values, that should be apparent with some quick statistics. I hashed 100,000 synthetic Matrix user IDs (@user0:matrix.org through @user99999:matrix.org) and bucketing the results by hash % 360 (the first step in the color() algorithm above). If the ir hashes evenly, then distributing the results across 10-degree bands of the 360º hue wheel means we should see about 2,778 (100,000 / 36) hashes per band. It turns out, every 10-degree band of the hue wheel received between 2,664 and 2,902 names -- within ~5% of the expected 2,778.

So the hash itself wasn't the problem. DJB2 modulo 360 distributes remarkably uniformly across the 360 integer hue values. Something else was wrong.

Measuring the problem

So I started to wonder about color itself. Were all the purples really the same purple? To test this, I split the hue range into 7 discrete "colors" myself, picking places where I feel like the color really shifted from one to another. I ended up with: red, purple, blue, cyan, green, yellow and orange. I bucketed 35 sample Matrix user IDs into these categories:

Category Hue range Count % of samples
Green 70–149 10 29%
Purple 260–329 11 31%
Cyan 150–194 4 11%
Yellow 45–69 3 9%
Blue 195–259 3 9%
Red 0–14, 330–359 2 6%
Orange 15–44 2 6%

Sixty percent of all names landed in Green or Purple. And within those groups, the collisions were literal: @frank:matrix.org and @grace:matrix.org both hashed to hue 188. @charlie:matrix.org and @oscar:matrix.org both hit 130. @quinn:matrix.org and @ruth:matrix.org -- both 285.

This wasn't a hash problem. So this was a color space problem?

The HSB hue wheel is a lie

Okay, not a lie. But it's not what most people think it is. (I leveraged AI here to help me distill the color space domain into something that can fit in a blog post.)

HSB (Hue, Saturation, Brightness) is a convenient mathematical transformation of RGB. Its hue axis is a 360-degree wheel, and it's tempting to assume that equal angular steps on that wheel produce equal perceived color differences. They don't.

HSB hue is a mathematical transformation of RGB, not a perceptual measurement. You can see this by looking at the hue sweep: the green region spans a huge visual range where neighboring degrees are nearly indistinguishable, while the red-orange region changes rapidly. This non-uniformity is well-known in color science -- it's the reason perceptually uniform color spaces like CIELAB and OKLab exist in the first place. (But I get ahead of myself...)

This means that in HSB:

When you uniformly distribute hash values across this wheel, 41% of your users will land in "green or purple," even though the hash is perfectly fair. The color space, it turns out, is unfair.

HSB (Old) Light

Look at the hue sweep bar above. Notice how quickly yellow transitions to green, how much of the middle is nearly visually-identical green, and how the cyan-to-blue transition is nearly as abrupt as yellow-to-green. The reds and oranges are vivid and varied, but they occupy a tiny fraction of the bar. When you add low saturation (0.45) to make the colors suitable for backgrounds, the perceived gamut compresses even further. Everything turns into a washed-out version of one of six or seven named colors.

Down the rabbit hole: OKLab and OKLCH

Once I understood the problem was perceptual non-uniformity, the question became: is there a color space where equal steps do produce equal perceived differences?

It turns out this is one of the oldest problems in color science. The CIE tried to solve it in 1976 with CIELAB, which improved on raw RGB but still had significant non-uniformity, especially in the blue region. For decades, color scientists refined successive models. Then, in 2020, Björn Ottosson published OKLab -- a perceptually uniform color space that's both accurate and computationally simple. Ottosson's blog post explains it, but I will try to summarize it here. I'll be the first to admit, I am not a color space scientist and I don't deeply understand the math here.

OKLab has three axes:

OKLCH uses fancy math and maps OKLab's axes into something more intuitive:

The crucial difference here is that equal angular steps in OKLCH hue produce equal perceived color differences. A 10-degree shift looks the same whether you're in the reds, the greens, or the purples. This is exactly what HSB fails to deliver.

The conversion math

There's no native Color(oklch:) initializer in SwiftUI or AppKit, but the math is compact, and Ottosson's blog post has a sample implementation in C++. The conversion chain is:

OKLCH (L, C, H)
  -> OKLab (L, a, b)       // polar to Cartesian
  -> Linear sRGB (r, g, b) // two 3x3 matrix multiplies via LMS
  -> sRGB (r, g, b)        // gamma transfer function

In Swift, the whole thing fits in about 20 lines of pure arithmetic:

static func oklchToSRGB(L: Double, C: Double, H: Double) -> (CGFloat, CGFloat, CGFloat) {
    // OKLCH -> OKLab (polar to Cartesian).
    let hRad = H * .pi / 180.0
    let a = C * cos(hRad)
    let b = C * sin(hRad)

    // OKLab -> linear sRGB via LMS intermediary.
    // Matrices from Bjorn Ottosson: https://bottosson.github.io/posts/oklab/
    let l_ = L + 0.3963377774 * a + 0.2158037573 * b
    let m_ = L - 0.1055613458 * a - 0.0638541728 * b
    let s_ = L - 0.0894841775 * a - 1.2914855480 * b

    let l = l_ * l_ * l_
    let m = m_ * m_ * m_
    let s = s_ * s_ * s_

    let lr = +4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s
    let lg = -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s
    let lb = -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s

    // Linear sRGB -> sRGB (gamma).
    return (
        CGFloat(linearToSRGB(lr)),
        CGFloat(linearToSRGB(lg)),
        CGFloat(linearToSRGB(lb))
    )
}

private static func linearToSRGB(_ x: Double) -> Double {
    let clamped = min(1, max(0, x))
    if clamped <= 0.0031308 {
        return 12.92 * clamped
    }
    return 1.055 * pow(clamped, 1.0 / 2.4) - 0.055
}

There is a Swift package that brings a lot of color standards to the language, but I didn't want to pull in a dependency for a single use case.

Choosing the right Lightness and Chroma

OKLCH separates the three perceptual dimensions, which makes tuning straightforward:

One subtlety: not every OKLCH triplet maps to a valid sRGB color. At high chroma, some hues exceed what a standard display can show. But at these conservative chroma values, essentially all 360 hues are safely in-gamut. The linearToSRGB function clamps to [0, 1] as a safety net, but in practice it should never activate.

The result

The updated color(for:) function has the same signature. The DJB2 hash stays. The hash % 360 stays. Only the color space changed:

static func color(for name: String) -> Color {
    let hash = djb2Hash(name)
    let hueDegrees = Double(hash % 360)

    let nsColor = NSColor(name: nil) { appearance in
        if appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua {
            let (r, g, b) = oklchToSRGB(L: 0.55, C: 0.12, H: hueDegrees)
            return NSColor(srgbRed: r, green: g, blue: b, alpha: 1)
        } else {
            let (r, g, b) = oklchToSRGB(L: 0.72, C: 0.11, H: hueDegrees)
            return NSColor(srgbRed: r, green: g, blue: b, alpha: 1)
        }
    }
    return Color(nsColor: nsColor)
}

OKLCH (New) Light

Compare the hue sweep bars. Where HSB had a large band of green and an abrupt cyan-blue cliff, OKLCH transitions smoothly and evenly across the full spectrum. Every degree of hue is visually distinct from its neighbors at a consistent rate.

After

In the app itself, the difference is immediate. While I don't think it's obvious enough from these screenshots given the small number of room members represented, the difference is noticeable. The overall palette feels more vibrant and friendly.

What I learned

The bug was in my assumptions, not my code. The hash was fair. The modulo was correct. The saturation and brightness were reasonable. Everything was "working." But I was treating HSB hue as if it were perceptually linear, and it isn't. The algorithm was uniformly distributing values across a non-uniform space.

Color spaces encode design decisions. HSB was designed to be a convenient transformation of RGB for UI color pickers, not for perceptual uniformity. Using it for perceptual tasks -- like "make these colors look different from each other" -- is a category error. OKLCH was designed specifically for perceptual uniformity, and it delivers.

Conclusion

Every user's color changed. This is the tradeoff: switching color spaces is a breaking cosmetic change. Every avatar shifts. But the old colors were broken in a way that mattered -- users in the same room looked the same. The new colors are correct in a way that the old ones couldn't be, no matter how much you tweaked the saturation slider.

This was a fascinating learning experience. While I think I had an assumption that color spaces were a complicated science, I had no idea how deep the field really goes. I barely scratched the surface, and had many of my assumptions challenged completely. It's an absolutely thrilling experience to learn that past assumptions are categorically wrong.

#OKLCH #macOS