SuperGeekery: A blog probably of interest only to nerds by John F Morton.

A blog prob­a­bly of inter­est only to nerds by John F Mor­ton.

Create mathematically generated CSS color schemes with OKLCh.

Wonderful World of Color
A wonderful world of color as imagined by ChatGPT.

The narration of this post was created with Bespoken plugin for Craft CMS.

The color scheme of SuperGeekery depends on the day you read this blog post. If you don't like the colors you see today, return tomorrow, and they'll be different. This post will show you how I generate each day's color scheme. It's based on a single color chosen based on the day of the year. I then use CSS's relatively new OKLCh color function to generate 20 possible colors throughout the site. 

The Oklchroma site inspired me to make this change. (And no, that is not a misspelled word.) The site is a CSS color utility that creates an array of colors using the OKLCh color space. Its About page details how the range of colors is generated using a sine wave algorithm inspired by Matthias Ott‘s presentation at CSS Day 2024. (I'm building on the work shared by others. Isn't the open web great?)

Before I show you how I set up my generated color scheme, let's talk about the wonderful world of color

What’s OKLCh? 

OKLCh is a new color space designed to be more perceptually uniform than earlier models like sRGB or HSL. It’s part of the ongoing CSS Color Module Level 4 and Level 5 work. (And it's supported in modern browsers.)

I hear you, or at least I think I do. Aren't there already many ways to define color in CSS? Yes. You can read the color functions part of the Level 4 docs for the complete list. Depending on how you count the options, there are 8 to 10 different functions. But it turns out there are problems with the existing color functions.

What problems? As one example, the Level 4 color spec mentions the following issue.

The hue angle in HSL is not perceptually uniform; colors appear bunched up in some areas and widely spaced in others.

I don't claim to understand the nitty-gritty details. Still, I know I've tried to create fully mathematically generated color palettes in the past and ended up not using them because I was not happy with the results. The OKLCh color space is intended to address the shortcomings of previous color functions.

The Level 5 documentation explains why OKLCh is ideal for calculating color pallets.

Because OKLCh is both perceptually uniform and chroma-preserving, and because the axes correspond to easily understood attributes of a color, OKLCh is a good choice for color manipulation.

Equal numerical changes in the L (Lightness), C (Chroma), or H (Hue) components of an OKLCh color will result in roughly equal perceived changes in the color. In other words, the colors you get are more predictable. OKLCh should fix the problems I've encountered in previous attempts.

How to use OkLCH

Here’s what an OKLCh color function would look like in your CSS.

/* 
Example of a nice blue color
oklch( 50% lightness, 0.2 chroma, 220 degree hue, optional 100% alpha ); 
*/
background-color: oklch( 50.0% 0.2 220 / 1.0 ); 

The oklach function accepts three required parameters and a fourth optional one.

Lightness: 0% to 100% - This ranges from dark, as in black, to very bright, as in white.

Chroma: 0.0 to 0.4 - This is roughly the saturation value of a color. You can go above 0.4 in my experiments, but reference materials say that 0.4 is equivalent to 100% chroma. When you stay between 0.0 and 0.4, you achieve predictable colors, which is why we're using the OKLCh color function. (You won't end up in CSS detention if you experiment, though, so feel free to fly your color freak flag!)

Hue: 0 to 360 - This value is the degrees of a circle. It's a cylindrical color space. (Hold on to that question...)

Alpha: 0.0 to 1.0 - This is the alpha channel for the color.

A cylindrical color space?

As I explored the OKLCh color space, I went down the rabbit hole about cylindrical color spaces. Why is the hue value 0 to 360? Those are degrees in a full circle, and the color for each degree follows the colors in the order you'd expect to see if you separate a full spectrum of light. 

When you see a rainbow, the colors are in a predictable order. Did you learn the acronym Roy G. Biv in an art class in school? That lesson holds here.

  • 30 degrees - Red colors
  • 60 degrees  - Orange colors
  • 90 degrees - Yellow colors
  • 130 degrees - Green colors
  • 220 degrees - Blue colors
  • 270 degrees - Indigo colors
  • 310 degrees - Violet colors

I made a color wheel to help illustrate how this works with the OKLCh function. You can change the degree and see the resulting color.

That's enough color theory talk. In short, OKLCh will allow us to make a predictable color using math.

# The color pallet project

I started by outlining what I wanted to accomplish.

  • I want a primary color pallet.
  • I want a highlight color pallet.
  • The color pallet should be the same for 24 hours at a time.
  • I wanted everyone to get the same color pallet for each day.
  • Can it work without Javascript? If not, have a decent fallback.

Default CSS variables

In the header of each page on the site, I define a default color in a CSS variable named hue with a value of 240deg. Using this single value, I calculate ten color values for my primary color pallet and 10 for my secondary color pallet. 

:root {
--hue: 240deg;
--primary: oklch(50% 0.0 var(--hue));
--primary-base: 0.05;
--primary-10: oklch(from var(--primary) 10% calc(var(--primary-base) + (sin(1.0 * pi) * c)) h);
--primary-20: oklch(from var(--primary) 20% calc(var(--primary-base) + (sin(0.9 * pi) * c)) h);
--primary-30: oklch(from var(--primary) 30% calc(var(--primary-base) + (sin(0.8 * pi) * c)) h);
--primary-40: oklch(from var(--primary) 40% calc(var(--primary-base) + (sin(0.7 * pi) * c)) h);
--primary-50: oklch(from var(--primary) 50% calc(var(--primary-base) + (sin(0.6 * pi) * c)) h);
--primary-60: oklch(from var(--primary) 60% calc(var(--primary-base) + (sin(0.5 * pi) * c)) h);
--primary-70: oklch(from var(--primary) 70% calc(var(--primary-base) + (sin(0.4 * pi) * c)) h);
--primary-80: oklch(from var(--primary) 80% calc(var(--primary-base) + (sin(0.3 * pi) * c)) h);
--primary-90: oklch(from var(--primary) 90% calc(var(--primary-base) + (sin(0.2 * pi) * c)) h);
--primary-100: oklch(from var(--primary) 100% calc(var(--primary-base) + (sin(0.1 * pi) * c)) h);
--secondary-complement-hue: calc(var(--hue) + 180deg);
--secondary: oklch(50% 0.0 var(--secondary-complement-hue));
--secondary-base: 0.05;
--secondary-10: oklch(from var(--secondary) 10% calc(var(--secondary-base) + (sin(1.0 * pi) * c)) h);
--secondary-20: oklch(from var(--secondary) 20% calc(var(--secondary-base) + (sin(0.9 * pi) * c)) h);
--secondary-30: oklch(from var(--secondary) 30% calc(var(--secondary-base) + (sin(0.8 * pi) * c)) h);
--secondary-40: oklch(from var(--secondary) 40% calc(var(--secondary-base) + (sin(0.7 * pi) * c)) h);
--secondary-50: oklch(from var(--secondary) 50% calc(var(--secondary-base) + (sin(0.6 * pi) * c)) h);
--secondary-60: oklch(from var(--secondary) 60% calc(var(--secondary-base) + (sin(0.5 * pi) * c)) h);
--secondary-70: oklch(from var(--secondary) 70% calc(var(--secondary-base) + (sin(0.4 * pi) * c)) h);
--secondary-80: oklch(from var(--secondary) 80% calc(var(--secondary-base) + (sin(0.3 * pi) * c)) h);
--secondary-90: oklch(from var(--secondary) 90% calc(var(--secondary-base) + (sin(0.2 * pi) * c)) h);
--secondary-100: oklch(from var(--secondary) 100% calc(var(--secondary-base) + (sin(0.1 * pi) * c)) h);
}

The 10 primary colors are lifted directly from the Oklchroma color utility site I mentioned earlier. 

My 10 secondary colors are derived from the hue color used for the primary colors. 

--secondary-complement-hue: calc(var(--hue) + 180deg)

Complementary colors are pairs of colors opposite each other on the color wheel. We can use the cylindrical color wheel to our advantage by adding 180 degrees to the original value. (See Adobe's Kuler for examples of other color harmony formulas, like monochromatic, split complementary, etc., under the color harmony drop-down menu.)

The mathematically created primary and secondary colors are set, but I've intentionally muted them. The chroma value is set to zero.

--primary: oklch(50% 0.0 var(--hue));
...
--secondary: oklch(50% 0.0 var(--secondary-complement-hue));

If you loaded the page without Javascript, you'd get the muted default color pallet, but the Javascript kicks in when the site loads, and we reset the default hue value.

/**
 * Creates a pseudo-random float in the range [0, 1) using Math.sin
 * of a seed, ensuring a stable output for each unique integer seed.
 */
function seededRandom(daySeed: number): number {
  const x: number = Math.sin(daySeed) * 10000;
  return x - Math.floor(x);
}

/**
 * Returns a pseudo-random hue for the current day, but remains the same
 * for that day. The hue resets the next day.
 */
function getDailyHueSeeded(): number {
  const now: Date = new Date();
  const startOfYear: Date = new Date(now.getFullYear(), 0, 1);

  // Number of days since Jan 1
  const dayOfYear: number = Math.floor(
    (now.getTime() - startOfYear.getTime()) / (24 * 60 * 60 * 1000)
  );
  // Convert that to a random float
  const randomValue: number = seededRandom(dayOfYear);

  // Scale up to 360 deg
  return Math.floor(randomValue * 360);
}

const hueSeeded: number = getDailyHueSeeded();
document.documentElement.style.setProperty('--hue', `${hueSeeded}deg`);

// Set the chroma values to be more vibrant
document.documentElement.style.setProperty(
  '--primary',
  'oklch(50% 0.250 var(--hue))'
);
document.documentElement.style.setProperty(
  '--secondary',
  'oklch(50% 0.375 var(--secondary-complement-hue))'
);

There are several highlights in the JavaScript file to examine. 

We use a sine wave in the seededRandom function to ensure that the random number remains consistent throughout an entire 24-hour period. 

You might wonder why I didn't simply increment the hue value by one for each day that passed. That would have worked, but each subsequent day's color would only change slightly, and I wanted more change from day to day. 

Once we obtain the day's value in the getDailyHueSeeded function, we reset the CSS variable in the HTML header. This will trigger a recalculation of all the colors on the site. 

I also reset the chroma for both the primary and secondary colors, which overrides the default unsaturated version of the default color palette. 

And that's it! We now have an entirely new color palette for each day. 

What do you think? Are you sold on OKLCh? If this post inspires you to create something, I'd love to see it.