I have a confession to make. You probably sensed it, but weren’t able to articulate what was happening. Your loved ones think you’re losing your grasp on reality. For the last six months I’ve been incrementally changing the color scheme on my website every single day. I boiled you like a frog! Mu-wa-ha-ha. Don’t believe me? Try for yourself…

At the center of this CSS-trick is setting a --hue variable and rotating the hue about 1deg each day to make it 360º around the color wheel over the year. Because I like blue, I set the default --hue to 196 and add the current day’s --hue-rotate value to calculate my final --rotated-hue value.

--hue: 196;
--hue-rotate: <Floor ( CurrentDayOfYear * 365 / 360 )>; /* Math goes here */
--rotated-hue: calc(var(--hue) + var(--hue-rotate, 0)); /* Rotate it */

The math for the --hue-rotate isn’t that complex, but since CSS doesn’t have a Date() function, I set the initial --hue-rotate value inside my Liquid template when I generate the site with Jekyll.

{% assign yearProgress = 'now' | date: '%j' %}
<style>
  :root {
    --hue-rotate: {{ yearProgress | times: 360 | divided_by: 365 }};
  }
</style>

But I don’t post here every day, so I also update it with some JavaScript in the footer…

const dayOfYear = date => Math.floor(
  (date - new Date(date.getFullYear(), 0, 0)) / (1000 * 60 * 60 * 24)
);

const yearProgress = dayOfYear(new Date()) / 365;

document.documentElement.style.setProperty(
  '--hue-rotate', 
  Math.floor(yearProgress * 360)
);

Now my --rotated-hue value nudges itself every day starting from the base hue.

X-axis labelled “days of the year” and a y-axis labelled “hue”. The hue value starts in the middle of the y-axis and goes all the way up to the limit of 360 at half way through the year. Then it drops down vertically to zero and continues up at the same prior to the drop.

Background hue over time

I put --rotated-hue inside an hsl() and it worked okay, but was decidedly boring. I needed a way to make it more dynamic.

The next bit of magic was to use a slightly more complicated calc() function for the saturation value. Using some of the new CSS math functions, I take the cos() of the --rotated-hue to get a value between 1 and -1, then multiply that by 45% which is my “base saturation” for my light mode theme. That gives me a value between -45% and +45%, so I double it and add another 45% to avoid the gray zone of negative saturation.

:root {
  color-scheme: light dark;
  --hue: 196;
  --rotated-hue: calc(var(--starting-hue) + var(--hue-rotate, 0));
  --hue-rotate-deg: calc(var(--hue-rotate * 1deg)); /* Convert to degrees */
  --hue-rotate-cos: cos(var(--hue-rotate-deg)) /* Magic */
  
  --bg: hsl( 
      var(--rotated-hue)
      calc(45% * var(--hue-rotate-cos) + 45%) 
      96.5%
  );
}

That creates the following effect over time…

X-axis labelled days of the year and y-axis labelled saturation. An inverted parabola starts at 0.9 and dips down to zero in the middle of the year and then gently curves back up to 0.9

Background saturation over time

For my dark mode I wanted a darker website, so I lowered the base saturation to 25%

@media (prefers-color-scheme: dark) {
  :root {
    --bg: hsl( 
      var(--rotated-hue)
      calc(25% * var(--hue-rotate-cos) + 12.5%) 
      18.5%
    );
  }
}

Astute readers will notice I don’t add the full base saturation back to the value. This was intentional because as I slid the --hue value around I came across an ugly “background radiation” zone when --rotated-hue enters the green color space (h=75-115). For a month or two my site looked like dogshit. I minimized the undesired effect by letting it dip down into the gray in dark mode.

A shallower inverted parabola starts at 0.375 and dips down to -0.125 in the middle of the year and slopes back up.

Dark mode background saturation over time

As you can see in the chart above, lowering the base saturation creates a less dramatic wave function dipping below the 0% saturation. The resulting effect is that my light and dark mode now share the same --rotated-hue but have pretty different vibes. When I plot the two background saturations next to each other, you can see the relationship.

The previous two inverted parabolas layered on top of each other. The dark mode saturation curve sits below the light mode.

Light and dark mode background saturation over time

If someone asked me today “What color is your website?”, I sort of love the fact that I don’t know. In fact, I’d almost have to consult a chart on any given day…

The hue graph and hte background saturation curves overlayed on the same graph.

Hue, saturation, and lightness for light and dark mode over time.

Ah yeah. It makes perfect sense. It’s materialized color operating on the 49th vibration, you would make that conclusion walking down the street or going to the store.

Updating my accent color

The final step was improving my accent color. Finding a static color that had proper color contrast and worked well with every background color was difficult. I was hoping to find a shortcut but all roads led to making my accent color dynamic as well. How do I pick a color that works with every color? After some experiments, I leaned heavily on some third-grade art class color theory.

I found that rotating the --rotated-hue another 240º to get a triadic tone on the color wheel gave the accent color the best chance at standing out in both light and dark mode. You can see the relation to the background and accent hue when I plot them next to each other.

The previous background hue chart overlayed with the accent color hue that has the same curve but starts 120 points lower on the y-axis and follows the same zig zag diagonal pattern, but offset about 33%

Background and accent hue over time.

I decided to use oklch() instead of hsl() for my accent color because I wanted the vibrancy that can only come from the p3 color space.

--accent: oklch(
    75% 
    0.18
    calc(var(--rotated-hue) + 240) / 1
);

I tinkered with picking the color by hand so that I could try and maximize the number of days my accent color punches up into the p3 space.

Two color space representations next to each other. The first is the chroma value of 0.18 depicted by a rainbow gradient with red on left and violet on right but with a curving jagged peaks. A faint white line shows the boundary between rgb and p3 color space. Next is the hue of 196 representation but the slider has missing values where p3 is unable to render the color.

The oklch color graph from oklch.com for the accent color hue and chroma values

It’s more art than science here but I feel good about the results.

The accent color hue zig-zag graph with static chroma and lightness values forming horizontal lines near the bottom and top (respectively)

Accent Color (OKLCH)

Rotating colors is fun but not without its challenges

As far as challenges go, accessibility and proper color contrast is a concern. Contrast checkers don’t work the best with p3, but I’m sure that’s solvable. One challenge was my button text which I set in #FFFFFF in attempt to maximize contrast. It’s not perfect but it’s right more than its wrong. This is one place where Safari’s CSS color-contrast() would be welcome to have in all browsers.

I’ve also scoped my theme to two dynamic colors; adding more is difficult. I re-did my syntax highlighting theme and it’s doing okay (I used the other triadic tone), but more colors is more problems and the complexity balloons quick. Having some shades of gray in the style of Open Props would be helpful, so I may pursue that.

I’m happy with how it turned out but there’s plenty of room for improvement. I doubt I will ever stop obsessively tweaking the color formulas to get it juuuust right. One thing that bothers me now are the puke yellows in my accent color (like today’s) and the greens in the background color aren’t my favorite. I’m embracing the randomness, but I’d love a yellow like Piccalilli’s website. It might be possible to bump up the oklch() brightness in certain parts of the color journey, but requires more math.

It would also be awesome to figure out how to change colors over a custom gradient as opposed to the hue wheel. Could I use a step-frame animation but grab a point on a conic gradient? Hm…

Was it worth it? Did anyone notice? Yes, one person noticed. My coworker John is the only person who said anything. Good eye, John.

All said and done, I like my little rotating color scheme. It’s fun waking up and wondering what color my website is today. If you’re having fun with color on your website I’d love to see it. I want all the p3 inspiration I can get.