assing Data into SVG: Linked Parameters Workaround

Twelve years ago, I started a draft for an article. I never published it: there were a few cross-browser issues, and the idea was raw and unpolished. I have many drafts like this in my archives. Sometimes, something new interacts with one of these older ideas and leads to something interesting. This article is about one such case: a hacky technique that allows us to pass some data from CSS to SVG and use it to adjust colors or almost anything else.

he Spec

In the title I mentioned Linked Parameters — it is the name of one of CSS Working Group’s “Editor’s Draft” of a spec, with Tab Atkins-Bittner, Daniel Holbert, and Jonathan Watt as editors.

Let me quote from its abstract:

This spec introduces a way to pass CSS values into linked resources, such as SVG images, so that they can be used as CSS custom property values in the destination resource. This allows easy reuse of “templated” SVG images, which can be adapted to a site’s theme color, etc. easily, without having to modify the source SVG.

Unlike many other SVG-related specs, this one is relatively new (from 2023), but, as with almost anything related to SVG, browsers are not rushing to implement (or, at least, prototype) it. This is a shame, as that problem — passing data into SVG from CSS — would be infinitely useful. Yes, it is possible to dynamically apply CSS to SVG when it is present inside the HTML document itself, but when we use an SVG as an <img> or inside url() in CSS, we don’t have fine control over what is displayed there. Yes, we now have fragment identifiers, but that’s it.

We cannot pass just some random color into an SVG if we want to change it, and have to rely on hacks involving masks or filters.

But what if we had… another hack, but this time more powerful?

he Workaround Technique

How can an SVG know anything about its context if we are not using fragment identifiers?

The answer is in the “S” of SVG, which stands for “Scalable”.

When we use an SVG in our document, we can define some dimensions: width and height. And that SVG can then scale, respond, and adapt to them.

Long story short: we canGo to a sidenote encode some information for an SVG as its dimensions, using them as the channel of communication with the SVG’s content!

Look at these (and hover/focus them):

This is the new CSS logo (with its modified code), added as an external background-image to multiple elements:

background-image: url('./css-linked.svg');

There are no filters or masks, but we can see how it is possible to override both background and foreground colors, and there is even a “small” variation (note the spacing around “CSS” in it) — all without changing the url()!

he Inspirations

Before I go to the details of the technique’s implementation, I need to mention a few of the main sources of inspiration that led me to it.

he Spark

My most recent experiments on this started from a random Mastodon thread by Ivan Reese almost two months ago, in which he shared some work-in-progress bits of CSS with SVG inside. I’ll quote one of the challengesGo to a sidenote he had:

I’m trying to find a way to have them turn red on hover without using filter or mask or pseudo-elements.

Initially, I did not know if I could help with this: I remembered that there was a spec for linked parameters, but no browser implemented it; otherwise, it would be the best fit. Fragment identifiers could be a solution, but not a perfect one, as we’d have to hardcode the color inside.

The next morning, I woke up with a solution. Many puzzle pieces I was thinking about in the past weeks clicked together.

VG Stacks

When thinking about fragment identifiers, I was contemplating the concept of “SVG Stacks”. I was unsure if they could help with this issue, but maybe? The first time I can remember learning about them was from the SVG Stacks post by Simurai back in 2012 about this technique he attributed to Erik Dahlström.

At the time, browser support was just not there, and it was one of those “cool tech for the future” articles.

When I was thinking about SVG stacks, I also remembered two related articles about them and similar techniques:

While these articles were not strictly related to my technique, I still want to highlight them, as I will use the fragment identifier in some of my examples — SVG stacks can work pretty well alongside my technique.

lown Cars

Next, I remembered my unfinished draft for an article where I tried to implement SVG stacks with what we had then — something very close to the technique I will describe today. I never finished that article, but in my drafts I wrote about my main inspiration for it: How To Solve Adaptive Images In Responsive Web Design article by Estelle Weyl from 2013. It was talking about the “Clown Car Technique”, where we could put multiple images of different sizes inside one SVG, and then use media queries to choose one based on the SVG’s dimensions.

While media queries themselves can be powerful, they are not very convenient for the problem I wanted to solve. That’s where the next puzzle piece falls into place.

xfiltration

This January, Jane Ori published her latest wild experiment: 100% CSS: Fetch and Exfiltrate 512 bits of Server-Generated Data Embedded in an Animated SVG, in which she managed to encode some information inside an SVG, read it in CSS by using that image as a content: url(), and then getting the container’s dimensions.

I immediately understood that for my technique I’ll be doing something very similar, but in reverse and on a smaller scale: instead of having the data inside an SVG, and then reading it from CSS, I’ll encode the data in CSS, and then pass it inside an SVG via manipulating its dimensions!

After all, if we can use CSS inside SVG, and most of all the new greatness like @property and all the math functions work there as well — why not use it?

olours

Most of the remaining puzzle pieces were my experiments with colors in CSS, initiated by my conversation with Lea Verou, and later followed up by my Splash Colour Mixin post based on the Splash colour format by Lu Wilson. In these experiments, I already played with many ways of encoding and decoding colors — something I would need to do for my technique.

esponsiveness

The final details were various techniques for working with the responsiveness of SVG. After all, in this technique, we lose the dimensions of our SVGs, but we will still need to display the images inside, either in their original dimensions or properly scaled. That’s where Responsive SVGs article by Nils Binder from 2023 (published by Stephanie Eckles on her 12 Days of Web project) helped a lot.

And I cannot omit some other great resources that could help you understand how things work in SVG related to what I’ll be doing in my technique:

he Implementation

A usual disclaimer: this is highly experimental! The technique relies on adjusting the size of the SVG canvas, which can result in it being pretty large. If we use it for hundreds of icons on a page — I have no idea how browsers would behave. The technique also uses many newer CSS features. All together: I won’t recommend using it in production, unless you really know what you’re doing, and will test your code extensively.

he Core in CSS

The idea behind the technique is simple, but everything following it up is not. To start, we can use one or both dimensions of an SVG as its input. Let me show you the example from before again:

And here is its full CSS — the one that is outside the SVG file:

.icon {
  /* Not related to the technique */
  display: inline-block;
  place-self: center;
  background-origin: content-box;
  padding: 1rem;

  /* Icon’s dimensions */
  width: 100px;
  aspect-ratio: 1;

  /* Using the image */
  background-image: url('./css-linked.svg');
  background-repeat: no-repeat;
  background-size:
    100%
    calc(100% + var(--encoded-value) * 1px);

  /* #639 — rebeccapurple */
  --r: 6;
  --g: 3;
  --b: 9;

  /* Encoding 3 components in decimal */
  --encoded-value: calc(
    var(--r--hover, var(--r)) * 256
    +
    var(--g--hover, var(--g)) * 16
    +
    var(--b--hover, var(--b))
  );

  /* Dynamically overriding */
  a&:hover,
  a&:focus-visible {
    --r--hover: 15;
    --g--hover: 0;
    --b--hover: 0;
  }
}

So, the core of the technique is: we can encode the data we want to pass inside as a single decimal number and pass it via background-size.

In this case, we write our color — like #639 — as three separate custom properties, and then “combine” themGo to a sidenote into one number. Each color component becomes a 4-bit register (16 possible values — 2^4), so, in the end, we get a 12-bit --encoded-value.

Now, we use this value inside background-size, adding it to our icon’s size: calc(100% + var(--encoded-value) * 1px). This example features an always square icon, so we can use 100% there.

For now, we’re using only one dimension to pass some value, but I will also show how we can use both dimensions later.

he Core in SVG

Above was all we had to do in outer CSS — the easy part. We are now passing our encoded value inside an SVG, and so our SVG will get it as its inner document’s dimensions. If we were using media queries, like in the “Clown Car” technique, we could base them on these dimensions. Thankfully, we now have viewport units — which we can now use to retrieve the encoded dimension!

Here is most of the inner CSSGo to a sidenote that I am using inside SVG to retrieve the data we passed into it:

<style>/* <![CDATA[ */
  @property --vh {
    syntax: "<length>";
    initial-value: 0;
    inherits: false;
  }
  @property --vw {
    syntax: "<length>";
    initial-value: 0;
    inherits: false;
  }

  :root {
    --vh: 100vh;
    --vw: 100vw;
    --value: calc(
      10000 * tan(atan2(var(--vh) - var(--vw), 10000px))
    );

    --r: round(down,
      var(--value) / 256 + 0.0001,
    1);

    --g: round(down,
      (var(--value) - var(--r) * 256) / 16 + 0.0001,
    1);

    --b: calc(
      var(--value) - var(--r) * 256 - var(--g) * 16
    );

    --color: rgb(
      calc(var(--r) * 17),
      calc(var(--g) * 17),
      calc(var(--b) * 17)
    );

    --l: clamp(
      0,
      (l / var(--l-threshold, 0.71) - 1) * -infinity,
      1
    );
    --inverted-color:
      oklch(
        from var(--color)
        var(--l) 0 h
      );
  }
  #bg {
    fill: var(--color);
  }
  #fg {
    fill: var(--inverted-color);
    /* […] */
  }
  /* […] */

/* ]]> */</style>

Let’s go through this code step-by-step.

--vh: 100vh;
--vw: 100vw;
--value: calc(
  10000 * tan(atan2(var(--vh) - var(--vw), 10000px))
);

First, we retrieve the --value by using the dimensions of our SVG canvas: because we know that the icon is square, we calculate the value by subtracting the 100vw from 100vh, which will result in the value we added as the payload.

We need to register --vh and --vw; otherwise we couldn’t use them inside tan(atan2()) workroundGo to a sidenote. Note the 10000 — this is a workaround for one of the Firefox bugs I mentioned in my previous article.

Next, we decode our value into the color components:

--r: round(down,
  var(--value) / 256 + 0.0001,
1);

--g: round(down,
  (var(--value) - var(--r) * 256) / 16 + 0.0001,
1);

--b: calc(
  var(--value) - var(--r) * 256 - var(--g) * 16
);

The 0.0001 here is a workaround for another Firefox bug, but otherwise, this is just a reverse of what we did when we encoded the value.

Finally, we can get the encoded color as rgb():

--color: rgb(
  calc(var(--r) * 17),
  calc(var(--g) * 17),
  calc(var(--b) * 17)
);

Now we haveGo to a sidenote the --color that we use for the background, but what about the foreground color? In this specific example, instead of encoding another color, we can cut some corners and use a contrasting color to the one we decoded instead:

--l: clamp(
  0,
  (l / var(--l-threshold, 0.71) - 1) * -infinity,
  1
);
--inverted-color:
  oklch(
    from var(--color)
    var(--l) 0 h
  );

This is a technique from Lea Verou’s article On compliance vs readability: Generating text colors with CSS that uses a relative color syntax and is very handy for cases like this one.

alculated Responsiveness

There is also another small CSS snippet that I omitted from the above code that is not related to this article’s technique, but is still something I found to be very useful when dealing with SVG used in CSS:

#fg {
  --vw: 100vw;
  --progress: calc(tan(atan2(var(--vw) - 32px, 32px)));
  --x: clamp(0, var(--progress), 1);
  --1-x: (1 - var(--x));
  --scale: calc(
    1 * var(--x)
    +
    1.3 * var(--1-x)
  );
  --translate: calc(-20% * var(--1-x));
  transform:
    scale(var(--scale))
    translate(var(--translate), var(--translate))
  ;
}

This snippet uses the value of --vw that we already have, and calculates the progress value that we can use to scale the “CSS” text inside the icon when it becomes smaller than 64px. This is inspired by my experiments based on the CSS progress() function’s workarounds, which I did as a part of the Interpolate values between breakpoints CSSWG issue by Scott Kellum.

I took the transform value from the original small version of CSS logo, and used linear interpolation to apply these values gradually from 64px to 32px.

Originally, I wanted to use another encoded “bit” in our payload, but I decided to use a calculation instead: if we can logically determine when we want to switch from the normal version to a smaller one — why not use it? I could also use a simple media query, but where is the fun in this? So — linear interpolation it is.

he SVG Responsiveness

So far, I only mentioned what is going on with CSS: but it is also important to understand what is going on with SVG around it. After all, if we would take an icon and add some of these styles, we could not get the same result: there are many tricks around how to make the image scale — or not scale — in the way we need it.

Here is the framework for the SVG file that I ended up with (omitting all the parts not related to the technique):

<svg
  xmlns="http://www.w3.org/2000/svg"
  width="100%"
  height="100%"
>
  <defs>
    <symbol
      id="icon"
      viewBox="0 0 1000 1000"
      preserveAspectRatio="xMinYMin meet"
    >
      <path id="bg" d="" />
      <path id="fg" d="" />
    </symbol>
  </defs>

  <use href="#icon" />

  <style>/*…*/</style>
</svg>

You can compare it with the original code of this SVG icon, but here is what we have to do to convert such an icon to a linked one:

  1. The <svg> itself should have no viewBox, and should have width="100%" and height="100%". This allows our SVG to adjust its dimensions to the background-size.

  2. To better handle responsiveness, we will be using a <symbol> and <use>, and that <symbol> will have the same value of viewBox that the original <svg> had.

  3. We need to add preserveAspectRatio="xMinYMin meet" to the <symbol> — the meet value is similar to the background-size: contain, making the image scale down to completely fit in its available space, and the xMinYMin determines the position, placing the image in the top-left corner in this case.

  4. The <use> will be stretched over the whole canvas: initially, I added explicit x="0" y="0" width="100%" height="100%", but then found out that these are default values, and we can just omit them.

  5. The <path>s inside the <symbol> can have ids that will then be used in CSS to target these specific parts of the image.

That’s it! What happens with all of this: we’re treating the width of our canvas as the one that scales the image, and the height is used for passing the data. Our symbol takes the top sideGo to a sidenote of the canvas, and is visible in the background-image. The rest of the canvas is not visible, and is used for our payload.

ariations and Advanced Techniques

You could’ve already noticed that we did cheat in a few places: we did not encode the full 24 bits of an RGB color — instead of three 8-bit values (0–255 each), we used only 12 bits: three 4-bit ones (0–16 each).

The reason: after some experiments, I noticed that dimensions of the SVG canvas larger than ~65535 are not reliable, meaning that, for a single dimension, we have only 16 bit (2^16 = 65536) available, and we did not even pass the alpha component! Which we could do in our case — 16 bit would be just enough to pass something like #639A, but for #01234567 we need 32 bits, which requires… 2^32 = 4294967296 pixels in one dimension, which, no, we are unable to use.

There are many ways we could go around it: from using both dimensions, to splitting the image, to simplifying the palette.

sing Both Dimensions

So, what if we could use both dimensions? Instead of one 32-bit value, we could use two 16-bit: two times up to 65536.

In this example, I am using 0–255 values for R, G, B, and A channels: the first displays the same colors as before, but with the lightgreen and pink being now proper versions, as they were previously slighly different, and the second row also has a different alpha channel per icon. Here is one of the icon’s HTML, for example:

<a
  href="https://github.com/CSS-Next/logo.css"
  target="_blank"
  class="icon2"
  aria-label="CSS Logo"
  style="--r: 144; --g: 238; --b: 144; --a: 108;"
></a>

But wait… If we’re using both dimensions to pass some data, how does our SVG know about the size of the icon it should display?

It knows nothing. We completely separate the outer and inner dimensions and rely on the icon wrapper to scale our SVG via CSS.

Here is the outer CSS for this example:

@property --cqw {
  syntax: "<length>";
  initial-value: 0px;
  inherits: false;
}
.icon2 {
  /* Not related to the technique */
  display: inline-block;
  place-self: center;
  background-origin: content-box;
  padding: 1rem;

  /* Icon’s dimensions */
  width: 100px;
  aspect-ratio: 1;

  /* Establishing containment */
  container-type: size;
  overflow: hidden;

  /* #663399 — rebeccapurple */
  --r: 102;
  --g: 51;
  --b: 153;
  --a: 255;

  /* The rest is done on a pseudo */
  &::before {
    content: "";
    display: block;

    /* Inner dimensions, different from outer */
    --inner-size: 1000px;
    width: var(--inner-size);
    height: var(--inner-size);

    --cqw: 100cqw;
    --ratio: tan(atan2(var(--cqw), var(--inner-size)));
    transform: scale(var(--ratio));
    transform-origin: 0 0;

    /* Using the image */
    background-image: url('./css-both.svg');
    background-repeat: no-repeat;
    background-size:
      calc(100% + var(--encoded-w) * 1px)
      calc(100% + var(--encoded-h) * 1px);

    @container (width < 64px) {
      background-image: url('./css-both.svg#mini');
    }

    /* Encoding first 2 components in decimal */
    --encoded-w: calc(
      var(--r--hover, var(--r)) * 256
      +
      var(--g--hover, var(--g))
    );

    /* Encoding last 2 components in decimal */
    --encoded-h: calc(
      (255 - var(--a--hover, var(--a))) * 256
      +
      var(--b--hover, var(--b))
    );
  }

  /* Dynamically overriding */
  a&:hover,
  a&:focus-visible {
    --r--hover: 255;
    --g--hover: 0;
    --b--hover: 0;
    --a--hover: 255;
  }
}

There are a few notable things.

We are using a pseudo-element inside, and establish a container on our element. This allows us to rely on the container for two things:

  1. Scale the icon — the pseudo-element should have fixed dimensions of the icon’s canvasGo to a sidenote — and we can use the tan(atan2()) method to scale the icon to fit the container. A bit similar to how I am doing this in my Fit-to-Width Text: A New Technique article, but much more simple, as the icon’s element is the only thing we need to scale.

  2. Because we lose the ability to understand the used size of the icon inside, we cannot use the calculated responsiveness and gradually adjust the version of the icon to a smaller one. That’s where we can use a regular container query, as well as a fragment identifier: using a small version of the icon for icons smaller than 64px.

The rest of this outer CSS is mostly straightforward: we encode our R and G as the background-image’s width, and B and A as its heightGo to a sidenote.

The differences in css-both.svg compared to css-linked.svg are minimal. Decoding the four values is straightforward, and the only tricky part is how we apply the mini version. For this, we add an id="mini" on the <use>, and set a custom property on it when it is targeted, which is then used by the corresponding path:

#fg {
  transform: var(--transform);
}

#mini:target {
  --transform:
    scale(1.3)
    translate(-20%, -20%);
}

I played with a few alternative ways of doing it, like trying to target the paths or trying to target the <svg> element itself, but these did not work in Firefox, so I ended up relying on passing a custom property through the <use> to the path inside.

Ah, and you can spot that the foreground color stays fully opaque: the original contrast function would’ve adjusted it as well, and would not consider the alpha channel for the lightness switch, so here is how I modified it:

--l: clamp(
  0,
  (l / var(--l-threshold, 0.71) - alpha) * -infinity,
  1
);
--inverted-color:
  oklch(
    from var(--color)
    var(--l) 0 h / 1
  );

Two changes:

  1. We can use alpha in the calculation of --l, subtracting its value when calculating the threshold. I found this works well enough.

  2. We need to explicitly mention the alpha inside the relative color syntax via / 1; otherwise it would inherit the original value from the --color.

In general, it is fun how we can use scaling outside the SVG to make it responsive differently, and get more “bandwidth” to control the image by using both dimensions. That’s 32 bits of information in two 16-bit blocks that we can use!

assing Multiple Colors

Now, having 32 bits is great, but what if we wanted to have even more? For example, what if instead of relying on the automatically calculated foreground color we’d like to define it as well? And do it with another full 32 bits? A common use case for this would be duotone icons.

Of course, we can do it:

Here, we pass two 32-bit colors, so 64 bits in total! Or do we? Here is what changed in the outer CSS compared to the previous example:

.icon-split {
  --a-bg: 255;
  --a-fg: 255;

  &::before {
    background-image:
      url('./css-split.svg#fg'),
      url('./css-split.svg#bg')
    ;
    background-size:
      calc(100% + var(--encoded-w-fg) * 1px)
      calc(100% + var(--encoded-h-fg) * 1px),
      calc(100% + var(--encoded-w-bg) * 1px)
      calc(100% + var(--encoded-h-bg) * 1px)
    ;

    --encoded-w-fg: calc(
      var(--r-fg--hover, var(--r-fg)) * 256
      +
      var(--g-fg--hover, var(--g-fg))
    );
    --encoded-h-fg: calc(
      (255 - var(--a-fg--hover, var(--a-fg))) * 256
      +
      var(--b-fg--hover, var(--b-fg))
    );

    --encoded-w-bg: calc(
      var(--r-bg--hover, var(--r-bg)) * 256
      +
      var(--g-bg--hover, var(--g-bg))
    );
    --encoded-h-bg: calc(
      (255 - var(--a-bg--hover, var(--a-bg))) * 256
      +
      var(--b-bg--hover, var(--b-bg))
    );
  }
}

Essentially, what is happening is we use the same image twice, but with different fragment identifiers. We are using the SVG stacks technique! If we can split the image into separate parts, we can display them selectively, and then pass our data to every part separately.

<defs>
  <symbol
    id="_bg"
    viewBox="0 0 1000 1000"
    preserveAspectRatio="xMinYMin meet"
  >
    <path fill="currentColor" d="" />
  </symbol>
  <symbol
    id="_fg"
    viewBox="0 0 1000 1000"
    preserveAspectRatio="xMinYMin meet"
  >
    <path fill="currentColor" d="" />
  </symbol>
</defs>
<use id="bg" href="#_bg" width="1000" height="1000" />
<use id="fg" href="#_fg" width="1000" height="1000" />
<style>
  /*…*/
  use {
    color: var(--color);
  }
  use:not(:target) {
    display: none;
  }
</style>

Splitting an image this way can be a powerful technique: in the above example, we managed to double our bandwidth.

If you kept track of what we were using, you might’ve noticed that in the example before we relied on fragment identifier and container query to apply the small version modifier. But now, we lost the fragment identifiers. With some more setup, we could return them, like if we had two fragment identifiers that would enable the corresponding part of the stack, but that feels rather cumbersome.

What if I said we could simplify (kinda) things a bit, remove the need for fragment identifiers, a need in a pseudo-element while keeping all 8-bit per color channel?

n Atempt to Simplify by Blending Colors

This could’ve been a fun example, but, sadly, it did not work out well:

Yes, we were able to simplify things, but we have two issues:

  1. We get this ugly aliasing for the rounded corners.

  2. We don’t have good control over the alpha channel without separating the image’s elements into several pseudo- (or real) elements.

But if you don’t have areas of semi-transparent pixels on the edges and don’t need an alpha channel, this can be a viable way to do things. How does it work?

background-blend-mode! Omitting some of the common stuff:

.icon3 {
  /* Using the image */
  background-image:
    url('./css-blend.svg'),
    url('./css-blend.svg'),
    url('./css-blend.svg'),
    url('./css-blend.svg'),
    url('./css-blend.svg'),
    url('./css-blend.svg')
  ;
  background-blend-mode:
    screen, screen, normal,
    screen, screen, normal;
  background-repeat: no-repeat;
  background-size:
    100% calc(100% + var(--encoded-r-fg) * 1px),
    100% calc(100% + var(--encoded-g-fg) * 1px),
    100% calc(100% + var(--encoded-b-fg) * 1px),
    100% calc(100% + var(--encoded-r-bg) * 1px),
    100% calc(100% + var(--encoded-g-bg) * 1px),
    100% calc(100% + var(--encoded-b-bg) * 1px);

  --encoded-r-bg:
    var(--r-bg--hover, var(--r-bg));
  --encoded-g-bg:
    calc(var(--g-bg--hover, var(--g-bg)) + 256);
  --encoded-b-bg:
    calc(var(--b-bg--hover, var(--b-bg)) + 512);

  --encoded-r-fg:
    calc(var(--r-fg--hover, var(--r-fg)) + 768);
  --encoded-g-fg:
    calc(var(--g-fg--hover, var(--g-fg)) + 256 + 768);
  --encoded-b-fg:
    calc(var(--b-fg--hover, var(--b-fg)) + 512 + 768);
}

Isn’t it fun? We can repeat the same background multiple times — without fragment identifiers — and essentially split it into channels, passing only one channel’s information to each copy of the image, alongside the required channel and the part we need to apply it to: foreground or background.

And we only used 11 bits per “image”, passing it as a single dimension and packing three different types of values in one. Even if this example did not work out, this method of packing things can be useful in general, like when we want to have several variations of some icon.

re-defined Palettes

Before now, we explored how we can try and pack all the necessary information as an actual color: passing all of its information into our SVG. However, in practice, we never have to deal with all the 4294967296 options that 32 bits of RGBA can produce.

In simple cases, we might want to switch some color between just a few values. The CSS Logo can be a good example: because it has a bespoke color defined for it, in practice, we likely won’t adjust it. However, there are also two monochrome versions: dark and light. Plus, we could want to have a separate hover state, like the one I used in all previous examples.

In this case, for just four different colors, we could’ve used just fragment identifiers, but we can also encode the index of the color we want to use in just two bits and then apply the one we want with color-mix():

Here we are mapping our four different colors to custom properties with indices from 0 to 3:

.icon4 {
  /* … */

  --default: 0;
  --hover:   1;
  --light:   2;
  --dark:    3;

  background-size: 100% calc(100% + var(--value, 0) * 1px);
}

Then, when we need to apply one of the colors, we use its custom property:

<a
  href="https://github.com/CSS-Next/logo.css"
  target="_blank"
  class="icon4 restore-visuals"
  aria-label="CSS Logo"
></a>
<a
  href="https://github.com/CSS-Next/logo.css"
  target="_blank"
  class="icon4 restore-visuals"
  aria-label="CSS Logo"
  style="--value: var(--light); background-color: #000;"
></a>
<a
  href="https://github.com/CSS-Next/logo.css"
  target="_blank"
  class="icon4 restore-visuals"
  aria-label="CSS Logo"
  style="--value: var(--dark);"
></a>

Inside SVG, we separate our value into two bits, which allows us to easily map it onto color-mix():

:root {
  --default: #639;
  --hovered:  red;
  --light:   #FFF;
  --dark:    #000;

  --b1: round(down,
    var(--value) / 2 + 0.0001,
  1);
  --b2: calc(
    var(--value) - var(--b1) * 2
  );

  --color: color-mix(in oklch,
    color-mix(in oklch,
      var(--default),
      var(--hovered) calc(100% * var(--b2))
    ),
    color-mix(in oklch,
      var(--light),
      var(--dark) calc(100% * var(--b2))
    ) calc(100% * var(--b1))
  );
}

With color-mix() we can only choose between 2 colors each time, so for every added color we will need to nest more and more color-mix()es.

For more complex cases, like if we want to express some design system’s palette with many different colors, this will not be feasible. I tried to experiment with style queries inside SVG, but did not manage to do so: both Firefox and Safari had blocking issues for this. Maybe one day I will follow up on that.

uerrying the Actual Color

Most examples above applied the color by separating its color components into different custom properties.

But what if we want to do something like

<a
  href="https://github.com/CSS-Next/logo.css"
  target="_blank"
  class="icon2 encoded"
  aria-label="CSS Logo"
  style="--color: lightgreen"
></a>

Or, in other words, use an actual color, in this case --color: lightgreen, and somehow encode it, automatically? The following example doesGo to a sidenote exactly this!

How does this work? Are you sure you want to know? Hmm? Alright, look at this:

.encoded {
  --color: rebeccapurple;
  --p: from var(--color) 0 0 0 /;
  --_r1: rgba(var(--p) rem(r, 2));
  --_r2: rgba(var(--p) rem(round(down, r / 2), 2));
  --_r3: rgba(var(--p) rem(round(down, r / 4), 2));
  --_r4: rgba(var(--p) rem(round(down, r / 8), 2));
  --_r5: rgba(var(--p) rem(round(down, r / 16), 2));
  --_r6: rgba(var(--p) rem(round(down, r / 32), 2));
  --_r7: rgba(var(--p) rem(round(down, r / 64), 2));
  --_r8: rgba(var(--p) rem(round(down, r / 128), 2));

  --_g1: rgba(var(--p) rem(g, 2));
  --_g2: rgba(var(--p) rem(round(down, g / 2), 2));
  --_g3: rgba(var(--p) rem(round(down, g / 4), 2));
  --_g4: rgba(var(--p) rem(round(down, g / 8), 2));
  --_g5: rgba(var(--p) rem(round(down, g / 16), 2));
  --_g6: rgba(var(--p) rem(round(down, g / 32), 2));
  --_g7: rgba(var(--p) rem(round(down, g / 64), 2));
  --_g8: rgba(var(--p) rem(round(down, g / 128), 2));

  --_b1: rgba(var(--p) rem(b, 2));
  --_b2: rgba(var(--p) rem(round(down, b / 2), 2));
  --_b3: rgba(var(--p) rem(round(down, b / 4), 2));
  --_b4: rgba(var(--p) rem(round(down, b / 8), 2));
  --_b5: rgba(var(--p) rem(round(down, b / 16), 2));
  --_b6: rgba(var(--p) rem(round(down, b / 32), 2));
  --_b7: rgba(var(--p) rem(round(down, b / 64), 2));
  --_b8: rgba(var(--p) rem(round(down, b / 128), 2));

  --_a1: rgba(var(--p) rem(255 * alpha, 2));
  --_a2:
    rgba(var(--p) rem(round(down, 255 * alpha / 2), 2));
  --_a3:
    rgba(var(--p) rem(round(down, 255 * alpha / 4), 2));
  --_a4:
    rgba(var(--p) rem(round(down, 255 * alpha / 8), 2));
  --_a5:
    rgba(var(--p) rem(round(down, 255 * alpha / 16), 2));
  --_a6:
    rgba(var(--p) rem(round(down, 255 * alpha / 32), 2));
  --_a7:
    rgba(var(--p) rem(round(down, 255 * alpha / 64), 2));
  --_a8:
    rgba(var(--p) rem(round(down, 255 * alpha / 128), 2));

  &::before {
    @container style(--_r1: color(srgb 0 0 0)) { --r1: 1 }
    @container style(--_r2: color(srgb 0 0 0)) { --r2: 1 }
    @container style(--_r3: color(srgb 0 0 0)) { --r3: 1 }
    @container style(--_r4: color(srgb 0 0 0)) { --r4: 1 }
    @container style(--_r5: color(srgb 0 0 0)) { --r5: 1 }
    @container style(--_r6: color(srgb 0 0 0)) { --r6: 1 }
    @container style(--_r7: color(srgb 0 0 0)) { --r7: 1 }
    @container style(--_r8: color(srgb 0 0 0)) { --r8: 1 }

    @container style(--_g1: color(srgb 0 0 0)) { --g1: 1 }
    @container style(--_g2: color(srgb 0 0 0)) { --g2: 1 }
    @container style(--_g3: color(srgb 0 0 0)) { --g3: 1 }
    @container style(--_g4: color(srgb 0 0 0)) { --g4: 1 }
    @container style(--_g5: color(srgb 0 0 0)) { --g5: 1 }
    @container style(--_g6: color(srgb 0 0 0)) { --g6: 1 }
    @container style(--_g7: color(srgb 0 0 0)) { --g7: 1 }
    @container style(--_g8: color(srgb 0 0 0)) { --g8: 1 }

    @container style(--_b1: color(srgb 0 0 0)) { --b1: 1 }
    @container style(--_b2: color(srgb 0 0 0)) { --b2: 1 }
    @container style(--_b3: color(srgb 0 0 0)) { --b3: 1 }
    @container style(--_b4: color(srgb 0 0 0)) { --b4: 1 }
    @container style(--_b5: color(srgb 0 0 0)) { --b5: 1 }
    @container style(--_b6: color(srgb 0 0 0)) { --b6: 1 }
    @container style(--_b7: color(srgb 0 0 0)) { --b7: 1 }
    @container style(--_b8: color(srgb 0 0 0)) { --b8: 1 }

    @container style(--_a1: color(srgb 0 0 0)) { --a1: 1 }
    @container style(--_a2: color(srgb 0 0 0)) { --a2: 1 }
    @container style(--_a3: color(srgb 0 0 0)) { --a3: 1 }
    @container style(--_a4: color(srgb 0 0 0)) { --a4: 1 }
    @container style(--_a5: color(srgb 0 0 0)) { --a5: 1 }
    @container style(--_a6: color(srgb 0 0 0)) { --a6: 1 }
    @container style(--_a7: color(srgb 0 0 0)) { --a7: 1 }
    @container style(--_a8: color(srgb 0 0 0)) { --a8: 1 }

    --r: calc(
      var(--r1, 0)      + var(--r2, 0) *   2
      +
      var(--r3, 0) *  4 + var(--r4, 0) *   8
      +
      var(--r5, 0) * 16 + var(--r6, 0) *  32
      +
      var(--r7, 0) * 64 + var(--r8, 0) * 128
    );

    --g: calc(
      var(--g1, 0)      + var(--g2, 0) *   2
      +
      var(--g3, 0) *  4 + var(--g4, 0) *   8
      +
      var(--g5, 0) * 16 + var(--g6, 0) *  32
      +
      var(--g7, 0) * 64 + var(--g8, 0) * 128
    );

    --b: calc(
      var(--b1, 0)      + var(--b2, 0) *   2
      +
      var(--b3, 0) *  4 + var(--b4, 0) *   8
      +
      var(--b5, 0) * 16 + var(--b6, 0) *  32
      +
      var(--b7, 0) * 64 + var(--b8, 0) * 128
    );

    --a: calc(
      var(--a1, 0)      + var(--a2, 0) *   2
      +
      var(--a3, 0) *  4 + var(--a4, 0) *   8
      +
      var(--a5, 0) * 16 + var(--a6, 0) *  32
      +
      var(--a7, 0) * 64 + var(--a8, 0) * 128
    );
  }
}

@property --_r1
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r2
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r3
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r4
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r5
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r6
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r7
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_r8
  { syntax: "<color>"; initial-value: red; inherits: false }

@property --_g1
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g2
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g3
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g4
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g5
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g6
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g7
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_g8
  { syntax: "<color>"; initial-value: red; inherits: false }

@property --_b1
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b2
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b3
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b4
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b5
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b6
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b7
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_b8
  { syntax: "<color>"; initial-value: red; inherits: false }

@property --_a1
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a2
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a3
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a4
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a5
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a6
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a7
  { syntax: "<color>"; initial-value: red; inherits: false }
@property --_a8
  { syntax: "<color>"; initial-value: red; inherits: false }

Not a big deal: just using a relative color syntax to separate each component of a given color into bits, then using style queries to read them back and encode the result as the corresponding variables.

I know, I know. Aside from the obvious hackiness of this approach, there is a bigger issue: it won’t work with currentColor.

roblem of the Current Color

This is a rather interesting aspect of how currentColor and anything that accepts it works. In short: the computed value of it will always be just currentColor, and it is not substituted by the actual color until used.

And, given container style queries depend on the computed value of some custom property, it is not possible to “capture” some color on an element and “probe” it in the above way with color-mix() and style queries.

I am very sad because of this.

But I am happy I managed to at least achieve splitting any regularly computed color: this will be very useful for the next versions of my Pure CSS Mixin for Displaying Values of Custom Properties!

While the most common use case for SVG today is icons, there are more things we can do with it. Initially, I wanted to add many more use cases but found myself wanting to finish the article instead. Thus, I will look into just one such case, and I invite you to experiment with SVG for other purposes too.

This last demo was the use case that sparked this article. Ivan Reese was working on an implementation of link underlines for the Ink & Switch research lab’s website, with SVGs credited to Todd Matthews and Ink & Switch. The underlines should vary semi-randomly from one to another and change color alongside their link’s color when hovered or focused.

With the technique from this article, it is possible to achieve both:

Ok, here we go! Hello! Hello, World! Something else Whatever Yo! Anyways Final one

In this example, we can see how links have slightly different images as underlines, have a defined colorGo to a sidenote, and change this color on hover or focus.

The way I transform the color into Splash color and back is not as interesting and is covered in my Splash Colour Mixin blog post. The color is then passed into the SVG using the technique that I already explained, but two things are happening that I need to mention.

In this case, the scaling is rather interesting: we want to scale only in one direction (horizontally) while keeping the other direction always fixed. We don’t have a preserveAspectRatio value for this (after all, we’re not preserving it). To achieve it, I had to position all the shapes to the bottom with y="100%", and then move them back up with transform. Maybe there is a better way to do it, but I ended up keeping this one.

Second thing: how do we choose between different shapes? The SVG file contains eight of them, and from the above example, we can see that these links have seemingly different underlines. In the original thread, Ivan solved it by switching up the images with :nth-child(), and yes, we could do it here as well, and either use the “SVG stack” technique or pass the index as the information inside with our technique.

I am doing neither!

Inside our SVG, we have our eight shapes as symbols, applied via use:

<use href="#ul1" y="100%" height="4px" style="--i: 0" />
<use href="#ul2" y="100%" height="4px" style="--i: 1" />
<use href="#ul3" y="100%" height="4px" style="--i: 2" />
<use href="#ul4" y="100%" height="4px" style="--i: 3" />
<use href="#ul5" y="100%" height="4px" style="--i: 4" />
<use href="#ul6" y="100%" height="4px" style="--i: 5" />
<use href="#ul7" y="100%" height="4px" style="--i: 6" />
<use href="#ul8" y="100%" height="4px" style="--i: 7" />

We can assign an index to them via an inline style (we could use sibling-index() in the future), and then we can use the inner SVG’s viewport width — which depends on the link text’s length — as our pseudo-randomness input, transform it into some index, and then use a calculation which will give only one of the shapes opacity: 1, and will make all other shapes invisible.

use {
  --vw: 100vw;
  --w: calc(10000 * tan(atan2(var(--vw), 10000px)));
  --index: round(down, rem(var(--w), 8), 1);
  opacity: var(--opacity, calc(
    1
    -
    clamp(
      0,
      max(
        var(--index) - var(--i),
        var(--i) - var(--index)
      ),
      1
    )
  ));
}

An ability to use the inner SVG dimensions and adjust things in this pseudo-random way is pretty cool and can enable various other decorative effects like this one.

inal Words

All of this is very hacky. But it opens a few doors for more dynamic customizable SVGs. In general, SVG feels neglected by the browser vendors, and I wish more work went into improving both the specs and implementations dealing with it.

The Linked Parameters sounds like a great step towards connecting external SVG files with CSS. Today, authors resort to complex build processes, JS-based solutions, or hacks like the one I outlined in this article to achieve this. This feature appears straightforward, and I hope browsers will start prototyping it as soon as possible.

Meanwhile: SVG is cool, and we should experiment with it more and see what else is missing for our practical needs.


Let me know what you think about this article on Mastodon!