Alert Email External Link Info Last.fm Letterboxd 0.5/5 stars 1/5 stars 1.5/5 stars 2/5 stars 2.5/5 stars 3/5 stars 3.5/5 stars 4/5 stars 4.5/5 stars 5/5 stars RSS Source Topic Twitter
I’m redesigning this site in public! Follow the process step by step at v7.robweychert.com.

Dynamic, Date-Based Color with JavaScript, HSL, and CSS Variables

A rational system for generating thousands of possible color schemes

Sometime during the development of Tinnitus Tracker, it occurred to me that color would be a good way to give its many entries—which span nearly three decades—a sense of time and place. Colors would change with the seasons and fade over time. In effect, every single day of the past 30 years would have a unique color scheme, each day looking slightly different than the day before. As a bonus, the color’s constant flux as you browse the site would evoke the vivid aesthetic of a wall of screen-printed gig posters. Each entry’s page would be kind of like a poster for the show it documented.

Gig poster references aside, Shaun Inman took a very similar approach to date-based color on his site back in 2006, and I was surprised when I didn’t see anyone else pick up the idea and run with it. More than a dozen years later, Tinnitus Tracker was my opportunity to do just that.

Establishing contrast ratios in grayscale

I began my color exploration like I usually do, by working out contrast in grayscale. This helped me decide that the site’s palette needed six main values, which I tested for WCAG color contrast compliance and then assigned to CSS variables:

<style>
  /* Grayscale */
  :root {
    --text-color: black;
    --text-bg-color: white;
    --meta-text-color: hsl(0,0%,5%);
    --meta-bg-color: hsl(0,0%,90%);
    --page-color: hsl(0,0%,40%);
    --accent-color: hsl(0,0%,80%);
  }
</style>

Since the colors will change on a per-page, per-date basis, these variables are stored in a style element in the document head. The first four will remain grayscale, but the last two (--page-color and --accent-color) will be assigned hues according to the date.

I can use these variables with any CSS property that takes a color value, like so:

background-color: var(--page-color);

Determining the date

Dynamically assigning values to my color variables requires JavaScript, so I created a function called dynamicColors():

function dynamicColors() { // Returns a date-based color scheme
}

Since the colors will be based on the date, dynamicColors()’s first task is to figure out what the date is. On an entry page (or “show” page, as it’s called on Tinnitus Tracker), that’s the date of the entry, which I added to a data attribute on the page’s body:

<body class="show" data-date="1992-08-15">

Same thing for a year archive page:

<body class="archive year" data-date="1992">

On any other page, the relevant date is the current date. dynamicColors() uses this information like so:

today = new Date();
thisYear = today.getFullYear();

if (document.body.classList.contains('show')) {
  yyyy = Number(document.body.dataset.date.slice(0,4));
  mm = Number(document.body.dataset.date.slice(5,7) - 1);
  dd = Number(document.body.dataset.date.slice(-2));
} else if (document.body.classList.contains('year')) {
  yyyy = Number(document.body.dataset.date);
  mm = today.getMonth();
  dd = today.getDate();
} else {
  yyyy = thisYear;
  mm = today.getMonth();
  dd = today.getDate();
}

This if statement basically says, If this is a show page or a year archive page, find the date in the body’s data-date attribute. Otherwise, use today’s date. Assign the year, month, and day to individual variables: yyyy, mm, dd.

Mapping hues to dates

Building a coherent framework for thousands of dynamic color schemes begins, in this case, with 12 hues, one for the first of each month. Since there are 365 days in a year and 360 degrees on the color wheel, giving the months equidistant placement on the wheel makes sense. Conveniently, the color temperature progression around the wheel matches the change of the seasons pretty well (at least in my part of the world), so the months all slot into place nicely. Each date’s degree on the wheel will be its --page-color hue value in HSL, and rotating that value by 120 degrees will determine the --accent-color hue.

Each month’s --page-color position on the color wheel.

The --page-color/--accent-color hue pairs for each month. Note how each column shares a trio of colors due to --accent-color’s 120º offset.

My hues for the first of each month are in place. Everything is too bright and the contrast is all over the place, but it’s a start.

The months’ hue degrees will eventually let me determine a unique hue for each individual day of the year. For example, if November 1st is 330º and December 1st is 300º, November 16th is 315º, right in the middle. In that sense, the months are like keyframes, points in a timeline against which points in between can be measured. (More on that in a bit.) So I created an array called keyframes, and within it, I assigned each of the date/hue pairs to a variable:

keyframes = [
  //     ---------------------------
  //     [     dayOfYear    pageH  ]
  //     ---------------------------
  jan1 = [             1,     270  ],
  feb1 = [  jan1[0] + 31,     240  ],
  mar1 = [  feb1[0] + 28,     210  ],
  apr1 = [  mar1[0] + 31,     180  ],
  may1 = [  apr1[0] + 30,     150  ],
  jun1 = [  may1[0] + 31,     120  ],
  jul1 = [  jun1[0] + 30,      90  ],
  aug1 = [  jul1[0] + 31,      60  ],
  sep1 = [  aug1[0] + 31,      30  ],
  oct1 = [  sep1[0] + 30,       0  ],
  nov1 = [  oct1[0] + 31,     -30  ],
  dec1 = [  nov1[0] + 30,     -60  ],
   end = [  dec1[0] + 31,     -90  ]
];

Note that each date is assigned a number based on what day of the year it is. This will make it easier to interpolate between dates, as we’ll see shortly. Rather than figuring out the numeric value of the first day of each month myself, I’m letting some simple dynamic math handle the task. All I need to know is the number of days in each month and that January 1st’s value is 1. January 1st’s value plus the number of days in January gives us February 1st’s value (1 + 31 = 32). February 1st’s value plus the number of days in February gives us March 1st’s value (32 + 28 = 60). And so on.

Note also that some hue values are negative. This is to maintain a descending chronology: the months move counter-clockwise around the color wheel, so for each day of the year, its hue value is less than the day before. This too will assist with interpolation, and luckily, even though HSL deals with hues between 0 and 360, it will accept any numeric value. As far as HSL is concerned, a hue expressed as −90 is the same as 360 − 90 (which is 270).

I could now use my keyframes array in conjunction with the date variables I established earlier (mm and dd) to find out what day of the year the current page’s date is:

dayOfYear = dd + keyframes[mm][0] - 1;

This line says, Use the current page’s month (mm, which has a numeric value) to access its corresponding keyframe (jan1’s index is 0, feb1’s index is 1, etc.), and find that keyframe’s day of the year. Then add that to the current page’s day of the month (dd) and subtract 1. For example, for February 10, whose day of the year is 41:

41 = 10 + 32 - 1;

Once I had the day of the year that corresponded with the page’s date (and stored it in a variable called dayOfYear), I could figure out what its hue value should be. This is where interpolation comes into play.

Interpolating hues

Recall that I’m referring to my date/hue variables (jan1, feb1, etc.) as keyframes. If you’ve ever worked with animation, you probably know that keyframes represent the beginning and end points of any particular movement or state change. The frames between those two points are known as in between frames, and in digital animation, they’re usually computer-generated. If I want to make a ball move a specified distance between Frame 25 and Frame 50, how far along will it be at Frame 31? This is the work of interpolation.

Let’s say I want to find out the correct hue for my birthday, June 3rd. Here’s the relevant information I have, based on my work so far:

Date Day of year Hue
June 1 151 20
June 3 153 ?
July 1 181 90

If I put that information into some variables…

  • x = June 1st’s day of year (151)
  • y = June 3rd’s day of year (153)
  • z = July 1st’s day of year (181)
  • a = June 1st’s hue (120)
  • b = June 3rd’s hue (?)
  • c = July 1st’s hue (90)

…I can use this linear interpolation formula to figure out June 3rd’s hue value:

b = a − (y − x) ⋅ ( (a − c) ÷ (z − x))

Here it is translated to a JavaScript function called interpolate():

function interpolate(posPresent, posPast, posFuture, attrPast, attrFuture) {
  return Math.round(attrPast - ((posPresent - posPast) * ((attrPast - attrFuture) / (posFuture - posPast))));
}

That function is used in dynamicColors() to assign hues to --page-color and --accent-color based on the page’s date:

for (i = 0; i < keyframes.length; i++) {
  if (dayOfYear >= keyframes[i][0] && dayOfYear < keyframes[i+1][0]) {
    pageH = interpolate(dayOfYear, keyframes[i][0], keyframes[i+1][0], keyframes[i][1], keyframes[i+1][1]);
    accentH = pageH + 120;
    break;
  }
}

This code loops through keyframes until it finds the two that the day in question sits between (e.g. June 3rd sits between jun1 and jul1). It then uses that information with interpolate() to determine --page-color’s correct hue (which it stores in a variable called pageH). Last but not least, it adds 120 to pageH to get --accent-color’s correct hue (which it stores in a variable called accentH).

This series of entries spanning January interpolates between the jan1 and feb1 hues.

Interpolating saturation

With my hues in good shape, I was ready to deal with saturation. I wanted --page-color to be fairly desaturated and --accent-color to be very saturated. If there were any justice in the world, I could have set a single saturation value for each and moved on. However, as Marc Green explains, different hues have different inherent saturation levels:

The most saturated yellow still appears pale compared to saturated red, saturated green and especially saturated blue. As a result, the number of distinguishable saturation levels is smaller in yellow than in the rest of the spectrum. One study concluded that there are only 10 saturation steps around yellow with the number gradually rising as wavelength increased or decreased. The lowest wavelengths, blue-violet had about 60 steps while the red reached about 50. Viewers will find small differences in blue, violet and red saturation highly discriminable while small differences in yellow saturation will be hard to detect. Green and orange are in the middle.

So a single pair of saturation settings wouldn’t produce consistent results with the full spectrum of hues used on the site. My final saturation settings wouldn’t come until close to the end of the process, but in the meantime I added placeholder settings for each month to keyframes:

keyframes = [
  //     ---------------------------------------------
  //     [     dayOfYear    pageH    pageS  accentS  ]
  //     ---------------------------------------------
  jan1 = [             1,     270,    0.20,    0.80  ],
  feb1 = [  jan1[0] + 31,     240,    0.20,    0.80  ],
  mar1 = [  feb1[0] + 28,     210,    0.20,    0.80  ],
  apr1 = [  mar1[0] + 31,     180,    0.20,    0.80  ],
  may1 = [  apr1[0] + 30,     150,    0.20,    0.80  ],
  jun1 = [  may1[0] + 31,     120,    0.20,    0.80  ],
  jul1 = [  jun1[0] + 30,      90,    0.20,    0.80  ],
  aug1 = [  jul1[0] + 31,      60,    0.20,    0.80  ],
  sep1 = [  aug1[0] + 31,      30,    0.20,    0.80  ],
  oct1 = [  sep1[0] + 30,       0,    0.20,    0.80  ],
  nov1 = [  oct1[0] + 31,     -30,    0.20,    0.80  ],
  dec1 = [  nov1[0] + 30,     -60,    0.20,    0.80  ],
   end = [  dec1[0] + 31,     -90, jan1[2], jan1[3]  ]
];

My preliminary saturation settings are in place and the colors are getting closer to where I want them.

Though they won’t be finalized until later, having saturation settings in place allowed me to set them up for interpolation in the same manner as the hues:

for (i = 0; i < keyframes.length; i++) {
  if (dayOfYear >= keyframes[i][0] && dayOfYear < keyframes[i+1][0]) {
    pageH = interpolate(dayOfYear, keyframes[i][0], keyframes[i+1][0], keyframes[i][1], keyframes[i+1][1]);
    pageS = Math.round(interpolate(dayOfYear, keyframes[i][0], keyframes[i+1][0], keyframes[i][2] * 100, keyframes[i+1][2] * 100)) * .01;
    accentH = pageH + 120;
    accentS = Math.round(interpolate(dayOfYear, keyframes[i][0], keyframes[i+1][0], keyframes[i][3] * 100, keyframes[i+1][3] * 100)) * .01;
    break;
  }
}

Note that while saturation values will ultimately be expressed in HSL as percentages, those won’t work with mathematic operations in JavaScript, so their equivalent decimal values are used here. The same is true of the final piece of the HSL puzzle, lightness.

Lightness versus luminance

As with saturation, it’s tempting to assume that a single pair of lightness settings for --page-color and --accent-color would do the trick across the whole site. After all, their grayscale versions already have the correct contrast, so I should be able to just use those same lightness values, right? Alas, it is not so. Just as different hues have different inherent saturation levels, they also have different perceived brightness, or luminance.

Even though all the hues in this spectrum have the same saturation and lightness settings, their perceived brightness varies widely.

This yellow and blue also have identical saturation and lightness settings, but the yellow’s inherently brighter luminance gives it much more contrast with the black background.

While this might have been solved in the same fashion as the saturation (with manual adjustments and interpolation), the existence of luminance calculators and grayscale conversion in image editing software made it clear to me that the process could be automated. And indeed, it didn’t take long to find a formula for calculating luminance from RGB values. Of course, I’m dealing with HSL, so I also needed to find formulas for converting HSL to RGB and back again. Those three formulas were devised by people smarter than me, so you can see the details of how they work in the posts linked above, but here they are translated to JavaScript for my purposes:

function HSLtoRGB(hsl) {
  h = hsl[0] / 360;
  s = hsl[1];
  l = hsl[2];
  rgb = [null, null, null];

  // Formula gratefully obtained from Nikolai Waldman at
  // http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/

  if (s == 0) { // If the color is grayscale
    shade = (s * 0.01) * 255;
    for (i = 0; i < 3; i++) {
      rgb[i] = shade;
    }
  } else {
    if (l < 0.50) {
      temp1 = l * (s + 1);
    } else {
      temp1 = (l + s) - (l * s);
    }

    temp2 = (l * 2) - temp1;
    rgb[0] = h + 0.333;
    rgb[1] = h;
    rgb[2] = h - 0.333;

    for (i = 0; i < 3; i++) {
      if (rgb[i] < 0) {
        rgb[i]++;
      } else if (rgb[i] > 1) {
        rgb[i]--;
      }
      if ((rgb[i] * 6) < 1) {
        rgb[i] = temp2 + (temp1 - temp2) * 6 * rgb[i];
      } else if ((rgb[i] * 2) < 1) {
        rgb[i] = temp1;
      } else if ((rgb[i] * 3) < 2) {
        rgb[i] = temp2 + (temp1 - temp2) * (0.666 - rgb[i]) * 6;
      } else {
        rgb[i] = temp2;
      }
      rgb[i] = Math.round(rgb[i] * 255);
    }
  }
  return rgb;
}

function RGBtoHSL(rgb) {

  // Formula gratefully obtained from Nikolai Waldman at
  // http://www.niwa.nu/2013/05/math-behind-colorspace-conversions-rgb-hsl/

  r = rgb[0] / 255;
  g = rgb[1] / 255;
  b = rgb[2] / 255;
  temp = [r, g, b];
  min = temp.sort()[0];
  max = temp.sort()[2];
  hsl = [null, null, null]
  hsl[2] = Math.round(((min + max) / 2) * 100) * 0.01; // Lightness

  if (min == max) { // If the color is grayscale
    hsl[0] = 0;
    hsl[1] = 0;
  } else {
    // Calculate saturation
    if (hsl[2] < 0.5) {
      hsl[1] = Math.round(((max - min) / (max + min)) * 100) * 0.01;
    } else {
      hsl[1] = Math.round(((max - min) / (2 - max - min)) * 100) * 0.01;
    }
    // Calculate hue
    if (r == max) {
      hsl[0] = (g - b) / (max - min);
    } else if (g == max) {
      hsl[0] = 2 + (b - r) / (max - min);
    } else {
      hsl[0] = 4 + (r - g) / (max - min);
    }
    hsl[0] = Math.round(hsl[0] * 60);
    if (hsl[0] < 0) {
      hsl[0] = hsl[0] + 360;
    }
  }
  return hsl;
}

function luminance(hsl) { // Returns a perceived brightness value between 0 and 1
  rgb = HSLtoRGB(hsl);
  r = rgb[0];
  g = rgb[1];
  b = rgb[2];

  // Formula gratefully obtained from Darel Rex Finley at
  // http://alienryderflex.com/hsp.html

  lum1 = Math.round(Math.sqrt(((r * r) * 0.299) + ((g * g) * 0.587) + ((b * b) * 0.114)));
  lum2 = [lum1, lum1, lum1];
  return RGBtoHSL(lum2)[2];
}

Just one more function was needed for my lightness/luminance task, one that adjusts lightness up or down as necessary until the color in question reaches the desired luminance:

function adjustLightness(currentLum, targetLum, hsl) {
  while (currentLum != targetLum) {
    if (currentLum < targetLum) {
      hsl[2] += 0.005;
    } else if (currentLum > targetLum) {
      hsl[2] -= 0.005;
    }
    currentLum = luminance(hsl);
  }
  hsl[2] = Math.round(hsl[2] * 100) * 0.01;
}

When used with adjustLightness(), a handful of new variables consolidating everything I’ve done so far in dynamicColors() will produce the final HSL values for --page-color and --accent-color:

pageTargetLum = 0.40;
pageHSL = [pageH, pageS, pageTargetLum];
pageLum = luminance(pageHSL);
accentTargetLum = 0.80;
accentHSL = [accentH, accentS, accentTargetLum];
accentLum = luminance(accentHSL);

adjustLightness(pageLum, pageTargetLum, pageHSL);
adjustLightness(accentLum, accentTargetLum, accentHSL);
  • pageTargetLum is the target luminance value for --page-color.
  • pageHSL constructs --page-color’s initial HSL value using the hue (pageH) and saturation (pageS) that were determined earlier. For now, pageTargetLum is used for the lightness value.
  • pageLum then calculates pageHSL’s current luminance value.

These are followed by corresponding variables for --accent-color.

The subsequent adjustLightness() function calls adjust the lightness values in pageHSL and accentHSL until they have the same luminance as, respectively, pageTargetLum and accentTargetLum. The process for adjustLightness() goes like this: Does this HSL color’s luminance match the target luminance? If it’s too dark or too light, increase or decrease the lightness by 0.005 (or 0.5%) and check the luminance again. Once the color’s luminance matches the target, round off the final lightness value to the nearest hundredth (or percentage point).

Now that lightness and luminance were automated, I could go back to keyframes and adjust the saturation levels until they looked uniform across all of the months:

keyframes = [
  //     ---------------------------------------------
  //     [     dayOfYear    pageH    pageS  accentS  ]
  //     ---------------------------------------------
  jan1 = [             1,     270,    0.10,    1.00  ],
  feb1 = [  jan1[0] + 31,     240,    0.11,    1.00  ],
  mar1 = [  feb1[0] + 28,     210,    0.20,    0.85  ],
  apr1 = [  mar1[0] + 31,     180,    0.22,    0.75  ],
  may1 = [  apr1[0] + 30,     150,    0.26,    1.00  ],
  jun1 = [  may1[0] + 31,     120,    0.20,    1.00  ],
  jul1 = [  jun1[0] + 30,      90,    0.28,    1.00  ],
  aug1 = [  jul1[0] + 31,      60,    0.28,    0.70  ],
  sep1 = [  aug1[0] + 31,      30,    0.32,    0.85  ],
  oct1 = [  sep1[0] + 30,       0,    0.18,    0.75  ],
  nov1 = [  oct1[0] + 31,     -30,    0.14,    0.75  ],
  dec1 = [  nov1[0] + 30,     -60,    0.10,    0.60  ],
   end = [  dec1[0] + 31,     -90, jan1[2], jan1[3]  ]
];

The final colors are in place! The combination of the hues’ offsets, the keyframes saturation settings, and the automated luminance gives everything a uniform look: Each month’s --accent-color pastel is a vibrant counterpoint to its more muted --page-color.

With HSL values finalized for --page-color and --accent-color, I had a functional system for dynamically generating a seasonally appropriate color scheme with uniform saturation and contrast ratios for every single day of the year!

Fading colors over time

dynamicColors()’s penultimate task, compared to the complicated process behind dealing with lightness and luminance, was mercifully straightforward. I wanted the colors on entry pages and year archive pages to fade according to how old they are. Instead of further manipulating my CSS color variables, I opted to just desaturate the entire page by putting a CSS grayscale() filter on the body. This way images and other elements on the page would be faded too. I just needed to make one addition to the date variables at the top of dynamicColors()

today = new Date();
thisYear = today.getFullYear();
firstYear = thisYear - 30;

…and then use our old friend interpolate():

grayscaleLevel = Math.round(interpolate(yyyy, firstYear, thisYear, 90, 0)) * 0.01;
  if (grayscaleLevel <= 0) {
    grayscaleStyle = '';
  } else {
  grayscaleStyle = 'body { -webkit-filter: grayscale(' + grayscaleLevel + '); filter: grayscale(' + grayscaleLevel + '); }';
}

This code determines the body’s grayscale() level (stored in a variable conveniently named grayscaleLevel). The closer the page’s date is to the current year, the less grayscale() it gets. If the page is 30 years old (firstYear) or more, it gets grayscale(0.9), which is almost completely desaturated.

The final grayscale() value is then deposited in a CSS rule in a string, stored in a variable called grayscaleStyle. If the page’s year is the same as the current year, the grayscale() value is 0 and grayscaleStyle is empty, because no desaturation is necessary.

The older an entry is, the more faded its colors are.

Final assembly

All that was left for dynamicColors() to do was to overwrite the contents of the style element in the document head to include the final grayscaleStyle, pageHSL, and accentHSL values, which is done like so (and note that the saturation and lightness values are finally converted from decimals to percentages):

document.getElementsByTagName('style')[0].innerHTML =
  grayscaleStyle
  + ':root { '
  + '--text-color: black;'
  + '--text-bg-color: white;'
  + '--meta-text-color: hsl(0,0%,50%);'
  + '--meta-bg-color: hsla(0,0%,90%);'
  + '--page-color: hsl(' + pageHSL[0] + ',' + pageHSL[1] * 100 + '%,' + pageHSL[2] * 100 + '%);'
  + '--accent-color: hsl(' + accentHSL[0] + ',' + accentHSL[1] * 100 + '%,' + accentHSL[2] * 100 + '%);'
  + ' }'
;

The page then calls dynamicColors() just after the opening body tag so the colors take effect as quickly as possible:

<body class="archive year" data-date="2019">
  <script>dynamicColors();</script>

And that’s a wrap! If there were a Tinnitus Tracker entry for every day of the last 30 years, this system would make a rational, unique color scheme for every single one. That’s nearly 11,000 color schemes! And with a few tweaks, it could easily make many more and/or expand the color system if necessary. Visit Tinnitus Tracker to see it in action.


In addition to being a fun project that documents something important to me, Tinnitus Tracker has been—and will continue to be—a great learning opportunity. The dynamic color system outlined in this post was a great chance to learn more about working with color and improve my JavaScript skills. And I’ve previously written about overcoming the site’s conceptual and structural challenges, as well as thinking about its layout in a more sophisticated way with the help of CSS Grid. Thanks for reading!