Dynamic Color Manipulation with CSS Relative Colors

I was reading Dave’s post “Alpha Painlet” when I first learned about CSS relative colors.

🤯

CSS relative colors enable the dynamic color manipulation I’ve always wanted in vanilla CSS since Sass’ color functions first came on the scene (darken(), lighten(), etc.).

Allow me to explain a bit more about why I’m so excited.

Dynamic Colors in CSS via Transparency

I’ve written about generating shades of color using CSS variables, which details how you can create dynamic colors using custom properties and the alpha channel of a supporting color function. For example:

:root {
  --color: 255 0 0;
}

.selector {
  background-color: rgb(var(--color) / 0.5);
}

However, there are limitations to this approach.

First, all your custom property color values must be defined in a color space whose notation supports the alpha channel in its color function, like rgb(), rgba(), hsl(), and hsla(). For example:

:root {
  --color-rgb: 251 0 0;
  --color-hsl: 5 10% 50%;
}

.selector {
  background-color: rgb(var(--color-rgb) / 0.5);
  background-color: hsl(var(--color-hsl) / 0.5);
}

You can’t “coerce” a custom property’s color value from one type to another:

:root {
  --color: #fb0000;
}

.selector {
  /* Coercing a HEX color to an RGB one doesn't work */
  background-color: rgb(var(--color) / 0.5);
}

Dynamic colors in CSS using HEX color values is impossible. While you can specify the alpha channel for a HEX color, you can only do so declaratively (i.e. #ff000080). CSS has no notion of concatenating strings.

:root {
  --color: #ff0000;
}

.selector {
  /* You can’t dynamically specify the alpha channel. */
  background-color: var(--color) + "80";
}

And if you’re using named colors in CSS, well, you’re flat out of luck trying to do anything dynamic.

:root {
  --color: green;
}

.selector {
  /* how would you even??? */
  background-color: var(--color) + "opacity: .5";
}

However, with relative colors in CSS this all changes!

You can declare a custom property’s value using any color type you want (hex, rgb, hsl, lch, even a keyword like green) and convert it on the fly to any other color type you want.

:root {
  --color: #fb0000;
}

.selector {
  /* can’t do this */
  background-color: rgb(var(--color) / 0.5);
  
  /* can do this */
  background-color: rgb(from var(--color) r g b / .5);
}

It even works with color keywords!

:root {
  --color: red;
}

.selector {  
  background-color: rgb(from var(--color) r g b / .5);
}

The easiest way for me to describe what’s happening here is to borrow terminology from JavaScript. With relative colors in CSS, you can declaratively perform a kind of type coercion from one color type to another and then destructure the values you want.

I don’t know if that blows your mind as much as it blew mine, but take a minute to let that soak in. Imagine the possibilities that begin to open up with this syntax.

Dynamic Colors in CSS via calc()

Dynamically changing colors using the alpha channel has its drawbacks. Transparent colors blend into the colors upon which they sit (you’re not always blending into white). You can take a color and get a “slightly lighter” version by changing its opacity, but that color won’t be the same everywhere. It is dependent upon which color(s) it sits on top of.

The color `#ff0000` with 50% opacity bleeding into two different background colors.

Sometimes you need a “slightly lighter” color without transparency. One that is opaque.

Or sometimes you need a “slightly darker” color, in which case you can’t set the alpha channel to 1.2 hoping it’ll get slightly darker.

Previously, you could achieve this flexibility in CSS by becoming incredibly verbose in your custom property definitions and defining each channel individually.

:root {
  /* Define individual channels of a color */
  --color-h: 0;
  --color-s: 100%;
  --color-l: 50%;
}

.selector {
  /* Dynamically change individual channels */
  color: hsl(
    var(--color-h),
    calc(var(--color-s) - 10%),
    var(--color-l)
  );
}

This could get really verbose really fast. And color values like hexadecimal colors are not supported.

With CSS relative colors, this is now dead simple in combination with calc().

:root {
  --color: #ff0000;
}
.selector {  
  color: hsl(from var(--color) h calc(s - 10%) l);
}

Wild! A few more examples, for completeness:

:root {
  --color: #ff0000;
}

.selector {
  /* syntax: hsl(from var() h s l / alpha) */
  
  /* change the transparency */
  color: hsl(from var(--color) h s l / .5);
  
  /* change the hue */
  color: hsl(from var(--color) calc(h + 180deg) s l);
  
  /* change the saturation */
  color: hsl(from var(--color) h calc(s + 5%) l);
  
  /* change all of them */
  color: hsl(
    from var(--color)
    calc(h + 10deg)
    calc(s + 5%)
    calc(l - 10%)
    /
    calc(alpha - 15%)
  );
}

Amazing! Sass color functions, let me show you the door.

Conclusion

Desctructuring? Type coercion? Do those words belong in a post about CSS? Is CSS a programming language?

The only thing we need now is the ability to have user defined custom functions in CSS—then you could create your own reusable lighten() and darken() functions.

But I digress.

Support for this syntax shipped in Safari Technology Preview 122 (check out some of the tests to see examples of the syntax). At the time of this writing, it’s still an experimental feature so you have to enable it via the menubar “Develop > Experimental Features”.

Related resources: