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 pointpermalink
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 colourspermalink
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 impactpermalink
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.