Applying P3 colours on an existing project

The set.studio site is powered by design tokens, which for colours, are hex codes. I managed to automatically convert those to P3 colours with a custom PostCSS plugin.


I’ll be honest. I am not smart enough to explain the new colour systems that CSS has in its ever-expanding toolset. What I can do though, is show you what a major impact they can have with not much effort.

The context today is our studio website. If you go there now, it’s using P3 colours. P3 is an RBG colour space, but the difference is there’s a wider gamut of colours available than your standard rgb(). Much like HDR (y’know, when your phone goes real bright when you’re browsing TikTok), P3 improves luminance and contrast which results in really vibrant colours.

The Set Studio website already has a really vibrant colour palette, so I was determined to see what impact even more vibrance has, but what I didn’t want to do is have to change all the design tokens or have two sets of colour tokens. Let’s dig in to how it was done.

What we have as a starting point permalink

Our CSS setup is based on the CUBE CSS Boilerplate. It takes JSON design tokens, formats them so Tailwind can understand then, then uses a combination of Tailwind’s standard utility capability, along with custom functionally to generate :root blocks of Custom Properties. You can read more about that here.

Our colour design tokens look like this:

Code language
json
{
  "title": "Colors",
  "description": "Hex color codes that can be shared, cross-platform. They can be converted at point of usage, such as HSL for web or CMYK for print.",
  "items": [
    {
      "name": "Dark",
      "value": "#030303"
    },
    {
      "name": "Dark Glare",
      "value": "#171717"
    },
    {
      "name": "Mid",
      "value": "#444444"
    },
    {
      "name": "Mid Glare",
      "value": "#cccccc"
    },
    {
      "name": "Light",
      "value": "#ffffff"
    },
    {
      "name": "Light Shade",
      "value": "#f7f7f7"
    },
    {
      "name": "Primary",
      "value": "#FF006A"
    },
    {
      "name": "Primary Glare",
      "value": "#ffeff6"
    },
    {
      "name": "Secondary",
      "value": "#00FFD4"
    },
    {
      "name": "Tertiary",
      "value": "#350DF2"
    },
    {
      "name": "Quaternary",
      "value": "#FFD501"
    },
    {
      "name": "Quinary",
      "value": "#00D5FF"
    },
    {
      "name": "Quinary Shade",
      "value": "#0AC"
    }
  ]
}

The CSS system generates a block like this:

Code language
css
:root {
  --color-dark: #030303;
  --color-dark-glare: #171717;
  --color-mid: #444444;
  --color-mid-glare: #cccccc;
  --color-light: #ffffff;
  --color-light-shade: #f7f7f7;
  --color-primary: #FF006A;
  --color-primary-glare: #ffeff6;
  --color-secondary: #00FFD4;
  --color-tertiary: #350DF2;
  --color-quaternary: #FFD501;
  --color-quinary: #00D5FF;
  --color-quinary-shade: #0AC;
}

Then all references to colours are using the var() function, like this generated utility class:

Code language
css
.text-primary {
  color: var(--color-primary);
}

We also use the custom properties in authored CMS like so:

Code language
css
.button[data-type='secondary'] {
  background: var(--color-dark);
  border: 1px solid var(--color-dark);
  color: var(--color-quaternary);
}

With that all in place, all I need to do is convert the hex colours into P3 colours, then generate another :root block of CSS Custom Properties, which will in turn, update everything!

Converting the colours permalink

I found this cool site. Luckily for me, it’s open source under the MIT licence, so I could use the colour converter in our codebase. The problem is, they’re using Typescript and we are not.

Step up, GPT 4. I’m not going go into the right and wrongs of “AI” here, but this is something I think GPT does a good job of. I asked it to convert the Typescript into JavaScript for me, which then gave me a basis to work from.

Code language
js
const toP3 = (color) => {
  if (!color) return undefined;

  // return unmodified if already in P3 color space
  if (color.includes("color(display-p3")) return color;

  // regex for matching HEX, RGB, RGBA colors
  const hexColorRegExp = /^#([0-9a-fA-F]{8}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/;
  const rgbColorRegExp = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/;
  const rgbaColorRegExp = /^rgba\((\d+),\s*(\d+),\s*(\d+),\s*([01]?(\.\d+)?)\)$/;

  let red = 0;
  let green = 0;
  let blue = 0;
  let alpha = 1;

  if (hexColorRegExp.test(color)) {
    // parse HEX with optional alpha
    const match = color.match(hexColorRegExp);
    if (match) {
      const hex = match[1];
      if (hex.length === 6 || hex.length === 8) {
        // HEX with or without alpha
        const step = hex.length === 8 ? 2 : hex.length / 3;
        red = parseInt(hex.slice(0, step), 16);
        green = parseInt(hex.slice(step, 2 * step), 16);
        blue = parseInt(hex.slice(2 * step, 3 * step), 16);
        alpha = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
      } else if (hex.length === 3 || hex.length === 4) {
        // 3 or 4 digit HEX
        red = parseInt(hex[0] + hex[0], 16);
        green = parseInt(hex[1] + hex[1], 16);
        blue = parseInt(hex[2] + hex[2], 16);
        alpha = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
      }
    }
  } else if (rgbColorRegExp.test(color)) {
    // parse RGB
    const match = color.match(rgbColorRegExp);
    if (match) {
      red = parseInt(match[1], 10);
      green = parseInt(match[2], 10);
      blue = parseInt(match[3], 10);
    }
  } else if (rgbaColorRegExp.test(color)) {
    // parse RGBA
    const match = color.match(rgbaColorRegExp);
    if (match) {
      red = parseInt(match[1], 10);
      green = parseInt(match[2], 10);
      blue = parseInt(match[3], 10);
      alpha = parseFloat(match[4]);
    }
  } else {
    // return transparent without converting if something doesn't match
    return "rgba(0, 0, 0, 0)";
  }

  // convert to P3 color space
  const r = (red / 255).toFixed(6);
  const g = (green / 255).toFixed(6);
  const b = (blue / 255).toFixed(6);
  const a = alpha.toFixed(6);

  return `color(display-p3 ${r} ${g} ${b} / ${a})`;
};

So far, ok. The problem we have now is that it’s sensible to both check that the user’s device supports the P3 colour gamut and also, the color function will work too.

Ideally, we want this sort of setup:

Code language
css
@media (color-gamut: p3) {
  @supports (color: color(display-p3 0 0 0 / 1)) {
    /* Root block of custom properties goes here */
  }
}

As far as I can tell, without great difficulty, I can’t generate this with Tailwind, so instead, I needed to make a PostCSS plugin. I also don’t know how to do that, but GPT does 😅

What GPT spat out was ok, but after giving it a thorough clean-up and actually referencing our design tokens, than whatever the hell this is, we have a plugin:

Code language
js
const postcss = require('postcss');
const colors = require('../design-tokens/colors.json');
const slugify = require('slugify');

function toP3(hexColor) {
  if (hexColor.includes('color(display-p3')) return hexColor;
  const hexColorRegExp =
    /^#([0-9a-fA-F]{8}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3})$/;

  let r = 0;
  let g = 0;
  let b = 0;
  let a = 1;

  // First test to see if this is a valid hext color
  if (hexColorRegExp.test(hexColor)) {
    const hex = hexColor.match(hexColorRegExp)[1];

    // Depending on the length of the hex code, parse out an RGB(A) value
    if (hex.length === 3 || hex.length === 4) {
      r = parseInt(hex[0] + hex[0], 16);
      g = parseInt(hex[1] + hex[1], 16);
      b = parseInt(hex[2] + hex[2], 16);
      a = hex.length === 4 ? parseInt(hex[3] + hex[3], 16) / 255 : 1;
    } else {
      let step = hex.length === 8 ? 2 : hex.length / 3;
      r = parseInt(hex.slice(0, step), 16);
      g = parseInt(hex.slice(step, 2 * step), 16);
      b = parseInt(hex.slice(2 * step, 3 * step), 16);
      a = hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1;
    }

    // Convert that RGB(A) value into display-p3 by dividing the extracted values by 255
    // 255 is the highest value for each R, G, and B
    return `color(display-p3 ${(r / 255).toFixed(6)} ${(g / 255).toFixed(6)} ${(
      b / 255
    ).toFixed(6)} / ${a.toFixed(6)})`;
  }

  // Fallback if not a valid HEX color
  return hexColor;
}

function generateP3Colors() {
  return function (root) {
    root.walkComments((comment) => {
      // If the /* generate-p3-colors */ is found
      if (comment.text === 'generate-p3-colors') {
        const rule = postcss.rule({selector: ':root'});

        // Loop each colour design token:
        // - Generate a custom property name
        // - Convert the hex to P3
        // - The key and value for the custom property to our rule
        colors.items.forEach((item) => {
          const propName = `--color-${slugify(item.name, {lower: true})}`;
          const convertedColor = toP3(item.value);
          rule.append({prop: propName, value: convertedColor});
        });

        // When all the processing is done, replace the comment with our new :root block
        comment.replaceWith(rule);
      }
    });
  };
}

module.exports = generateP3Colors();

All I need to do now, is add a /* generate-p3-colors */ CSS comment and the plugin will step in and dump a :root block of Custom Properties that have been generated. The source code looks like this:

Code language
css
@media (color-gamut: p3) {
  @supports (color: color(display-p3 0 0 0 / 1)) {
    /* generate-p3-colors */
  }
}

And the output looks like this:

Code language
css
@media (color-gamut: p3) {
  @supports (color: color(display-p3 0 0 0 / 1)) {
    :root {
      --color-dark: color(display-p3 0.011765 0.011765 0.011765 / 1.000000);
      --color-dark-glare: color(display-p3 0.090196 0.090196 0.090196 / 1.000000);
      --color-mid: color(display-p3 0.266667 0.266667 0.266667 / 1.000000);
      --color-mid-glare: color(display-p3 0.800000 0.800000 0.800000 / 1.000000);
      --color-light: color(display-p3 1.000000 1.000000 1.000000 / 1.000000);
      --color-light-shade: color(display-p3 0.968627 0.968627 0.968627 / 1.000000);
      --color-primary: color(display-p3 1.000000 0.000000 0.415686 / 1.000000);
      --color-primary-glare: color(display-p3 1.000000 0.937255 0.964706 / 1.000000);
      --color-secondary: color(display-p3 0.000000 1.000000 0.831373 / 1.000000);
      --color-tertiary: color(display-p3 0.207843 0.050980 0.949020 / 1.000000);
      --color-quaternary: color(display-p3 1.000000 0.835294 0.003922 / 1.000000);
      --color-quinary: color(display-p3 0.000000 0.835294 1.000000 / 1.000000);
      --color-quinary-shade: color(display-p3 0.000000 0.666667 0.800000 / 1.000000);
    }
  }
}

And with that, it’s job done!

The impact permalink

I tried to clip the before and after, but it’s so hard to see on a bitmap image. The best way to see the difference is look at this web archive version of the site, then switch over to the current live site. It’s pretty subtle, but for me on my Studio Display, it looks awesome!

Like I said at the start, I’m not fully versed in the new colour stuff. It seems like authoring OKLH is probably a better choice for readability. But, this setup works really well for its intended impact: giving existing colours a little boost where support allows that.

I’m diggin’ it.