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 col­or scheme of SuperGeek­ery depends on the day you read this blog post. If you don’t like the col­ors you see today, return tomor­row, and they’ll be dif­fer­ent. This post will show you how I gen­er­ate each day’s col­or scheme. It’s based on a sin­gle col­or cho­sen based on the day of the year. I then use CSS’s rel­a­tive­ly new OKLCh col­or func­tion to gen­er­ate 20 pos­si­ble col­ors through­out the site. 

The Oklchro­ma site inspired me to make this change. (And no, that is not a mis­spelled word.) The site is a CSS col­or util­i­ty that cre­ates an array of col­ors using the OKLCh col­or space. Its About page details how the range of col­ors is gen­er­at­ed using a sine wave algo­rithm inspired by Matthias Ott‘s pre­sen­ta­tion at CSS Day 2024. (I’m build­ing on the work shared by oth­ers. Isn’t the open web great?)

Before I show you how I set up my gen­er­at­ed col­or scheme, let’s talk about the won­der­ful world of col­or

What’s OKLCh? 

OKLCh is a new col­or space designed to be more per­cep­tu­al­ly uni­form than ear­li­er mod­els like sRGB or HSL. It’s part of the ongo­ing CSS Col­or Mod­ule Lev­el 4 and Lev­el 5 work. (And it’s sup­port­ed in mod­ern browsers.)

I hear you, or at least I think I do. Aren’t there already many ways to define col­or in CSS? Yes. You can read the col­or func­tions part of the Lev­el 4 docs for the com­plete list. Depend­ing on how you count the options, there are 8 to 10 dif­fer­ent func­tions. But it turns out there are prob­lems with the exist­ing col­or func­tions.

What prob­lems? As one exam­ple, the Lev­el 4 col­or spec men­tions the fol­low­ing issue.

The hue angle in HSL is not per­cep­tu­al­ly uni­form; col­ors appear bunched up in some areas and wide­ly spaced in oth­ers.

I don’t claim to under­stand the nit­ty-grit­ty details. Still, I know I’ve tried to cre­ate ful­ly math­e­mat­i­cal­ly gen­er­at­ed col­or palettes in the past and end­ed up not using them because I was not hap­py with the results. The OKLCh col­or space is intend­ed to address the short­com­ings of pre­vi­ous col­or func­tions.

The Lev­el 5 doc­u­men­ta­tion explains why OKLCh is ide­al for cal­cu­lat­ing col­or pal­lets.

Because OKLCh is both per­cep­tu­al­ly uni­form and chro­ma-pre­serv­ing, and because the axes cor­re­spond to eas­i­ly under­stood attrib­ut­es of a col­or, OKLCh is a good choice for col­or manip­u­la­tion.

Equal numer­i­cal changes in the L (Light­ness), C (Chro­ma), or H (Hue) com­po­nents of an OKLCh col­or will result in rough­ly equal per­ceived changes in the col­or. In oth­er words, the col­ors you get are more pre­dictable. OKLCh should fix the prob­lems I’ve encoun­tered in pre­vi­ous attempts.

How to use OkLCH

Here’s what an OKLCh col­or func­tion 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 func­tion accepts three required para­me­ters and a fourth option­al one.

Light­ness: 0% to 100% — This ranges from dark, as in black, to very bright, as in white.

Chro­ma: 0.0 to 0.4 - This is rough­ly the sat­u­ra­tion val­ue of a col­or. You can go above 0.4 in my exper­i­ments, but ref­er­ence mate­ri­als say that 0.4 is equiv­a­lent to 100% chro­ma. When you stay between 0.0 and 0.4, you achieve pre­dictable col­ors, which is why we’re using the OKLCh col­or func­tion. (You won’t end up in CSS deten­tion if you exper­i­ment, though, so feel free to fly your col­or freak flag!)

Hue: 0 to 360 — This val­ue is the degrees of a cir­cle. It’s a cylin­dri­cal col­or space. (Hold on to that ques­tion…)

Alpha: 0.0 to 1.0 — This is the alpha chan­nel for the col­or.

A cylindrical color space?

As I explored the OKLCh col­or space, I went down the rab­bit hole about cylin­dri­cal col­or spaces. Why is the hue val­ue 0 to 360? Those are degrees in a full cir­cle, and the col­or for each degree fol­lows the col­ors in the order you’d expect to see if you sep­a­rate a full spec­trum of light. 

When you see a rain­bow, the col­ors are in a pre­dictable order. Did you learn the acronym Roy G. Biv in an art class in school? That les­son holds here.

  • 30 degrees — Red col­ors
  • 60 degrees — Orange col­ors
  • 90 degrees — Yel­low col­ors
  • 130 degrees — Green col­ors
  • 220 degrees — Blue col­ors
  • 270 degrees — Indi­go col­ors
  • 310 degrees — Vio­let col­ors

I made a col­or wheel to help illus­trate how this works with the OKLCh func­tion. You can change the degree and see the result­ing col­or.

That’s enough col­or the­o­ry talk. In short, OKLCh will allow us to make a pre­dictable col­or using math.

# The color pallet project

I start­ed by out­lin­ing what I want­ed to accom­plish.

  • I want a pri­ma­ry col­or pal­let.
  • I want a high­light col­or pal­let.
  • The col­or pal­let should be the same for 24 hours at a time.
  • I want­ed every­one to get the same col­or pal­let for each day.
  • Can it work with­out Javascript? If not, have a decent fall­back.

Default CSS variables

In the head­er of each page on the site, I define a default col­or in a CSS vari­able named hue with a val­ue of 240deg. Using this sin­gle val­ue, I cal­cu­late ten col­or val­ues for my pri­ma­ry col­or pal­let and 10 for my sec­ondary col­or pal­let. 

: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 pri­ma­ry col­ors are lift­ed direct­ly from the Oklchro­ma col­or util­i­ty site I men­tioned ear­li­er. 

My 10 sec­ondary col­ors are derived from the hue col­or used for the pri­ma­ry col­ors. 

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

Com­ple­men­tary col­ors are pairs of col­ors oppo­site each oth­er on the col­or wheel. We can use the cylin­dri­cal col­or wheel to our advan­tage by adding 180 degrees to the orig­i­nal val­ue. (See Adobe’s Kuler for exam­ples of oth­er col­or har­mo­ny for­mu­las, like mono­chro­mat­ic, split com­ple­men­tary, etc., under the col­or har­mo­ny drop-down menu.)

The math­e­mat­i­cal­ly cre­at­ed pri­ma­ry and sec­ondary col­ors are set, but I’ve inten­tion­al­ly mut­ed them. The chro­ma val­ue 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 with­out Javascript, you’d get the mut­ed default col­or pal­let, but the Javascript kicks in when the site loads, and we reset the default hue val­ue.

/**
 * 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 sev­er­al high­lights in the JavaScript file to exam­ine. 

We use a sine wave in the seededRandom func­tion to ensure that the ran­dom num­ber remains con­sis­tent through­out an entire 24-hour peri­od. 

You might won­der why I did­n’t sim­ply incre­ment the hue val­ue by one for each day that passed. That would have worked, but each sub­se­quent day’s col­or would only change slight­ly, and I want­ed more change from day to day. 

Once we obtain the day’s val­ue in the getDailyHueSeeded func­tion, we reset the CSS vari­able in the HTML head­er. This will trig­ger a recal­cu­la­tion of all the col­ors on the site. 

I also reset the chro­ma for both the pri­ma­ry and sec­ondary col­ors, which over­rides the default unsat­u­rat­ed ver­sion of the default col­or palette. 

And that’s it! We now have an entire­ly new col­or palette for each day. 

What do you think? Are you sold on OKLCh? If this post inspires you to cre­ate some­thing, I’d love to see it.