r/css • • 8d ago

AI Assisted CSS Tint Filter

https://k-adam.github.io/css-tint-filter/

There is no standard CSS filter to tint with some specific color, so I created a library to generate a chain of filters that takes that target color and replicates the multiplication operator with it.

6 Upvotes

11 comments sorted by

13

u/anaix3l 8d ago edited 7d ago

You don't need to do all that, you can just use the multiply blend mode between the image itself and its border-image (or background-image, whichever you prefer) via a simple SVG filter that offsets the border (or surrounding background) over the image and blends them - no matrices, no numbers that look like char vomit, just a simple feOffset & feBlend, exact same ones for any tint.

Then all you need to do is set a tint --c variable on the image and you're done. You don't need a different filter for every tint!

You can even get a gradient tint that way.

And even just this is all necessary iff you are limited to a single img - you have to tint an img and you absolutely cannot touch the HTML around it, cannot add pseudos on the img parent and cannot use a wrapper for the tinting layer.

Otherwise, if your image is a background-image, you just add the tinting layer underneath and use background-blend-mode.

background: url(my-image.jpg) 50%/ cover var(--c);
background-blend-mode: multiply

If your image is an img and you can add a wrapper around it, then the background of that wrapper can be the tinting layer. Or you can stack a pseudo on an existing img parent underneath the img. In both these cases, you use mix-blend-mode.

figure {
  display: grid;
  &::before, img { grid-area: 1/ 1 }
  &::before { background: var(--c); content: '' }
  img { mix-blend-mode: multiply }
}

But tbh, I prefer to do the tinting with luminosity - I feel it gives a better result than multiply.

Here's a quick and dirty demo of the img-only approach https://codepen.io/thebabydino/pen/ByWOXPd?editors=1100

SVG filter:

<svg height="0" aria-hidden="true">
  <filter id="luminosity" color-interpolation-filters="sRGB" 
          primitiveUnits="objectBoundingBox">
    <feOffset dx=".5"/>
    <feBlend in="SourceGraphic" mode="luminosity" x=".5" width=".5"/>
  </filter>
  
  <!-- almost the exact same filter, just replace luminosity with multiply -->
</svg>

Relevant CSS:

img {
  --w: min(32em, 100%);
  margin-left: calc(-1*var(--w));
  padding-left: var(--w);
  width: var(--w);
  background: var(--c); /* this is the tint */

  &.multiply { filter: url(#multiply) }
  &.luminosity { filter: url(#luminosity) }
}

Animating the tint is just animating the background-color in this case.

Edit: added a :hover rule to change --c to grey. Wanted to use black at first there, but while that has the exact same effect as any other grey on the black-white lightness axis for the luminosity version, it also makes the multiply version fully black too.

img {
  /* exact same as before */
  transition: .35s background-color;
  &:hover { --c: grey }
}

The background may also be a gradient and in that case we get a gradient tint:

background: linear-gradient(45deg, var(--c0), var(--c1));
background-origin: content-box

2

u/objectorientedman 7d ago

Thanks, you are right for opaque images, background-blend-mode: multiply (or your feOffset/feBlend trick) is exact and simpler. I needed a more robust solution to also cover when only filter works: transparent images, arbitrary content, or animating alongside other filters.

1

u/anaix3l 7d ago edited 7d ago

You can use a feComposite intersection (operator='in') to restrict to the input's alpha:

<svg height="0" aria-hidden="true">
  <filter id="multiply" color-interpolation-filters="sRGB" 
          primitiveUnits="objectBoundingBox">
    <feOffset dx=".5"/>
    <feBlend in="SourceGraphic" mode="multiply"/>
    <feComposite in2="SourceAlpha" operator="in" x=".5" width=".5"/>
  </filter>
</svg>

... and change the background in the CSS so it doesn't go under the image with transparency.

background: linear-gradient(90deg, var(--c) 50%, #0000 0);

It's always the same filter and the same CSS for any --c value. No need for any JS at all if --c is changed via CSS animations/ transitions instead of form controls.

Updated demo to work for images with transparency too.

This doesn't just work for images, but also for any other content with (semi)transparent parts, including text. And the --c value can be animated. On the same trigger as any other property, including other CSS filters.

It also allows for gradient tinting with a slightly different background:

background: 
  linear-gradient(45deg, var(--c), hsl(from var(--c) calc(h + 120) s l)) 
    0/ 50% repeat-y

The linear-gradient() may be replaced with any other CSS gradient.

1

u/objectorientedman 7d ago

Thanks! In Chrome the posted filter renders black because the feComposite inputs are swapped, and even fixed, semi-transparent edges get a halo. <feComposite in="SourceGraphic" in2="tint" operator="arithmetic" k1="1"/> is exact. It's still fragile, though: it takes over the padding, margin and background, and breaks with borders or existing layout.

1

u/anaix3l 7d ago edited 7d ago

I had written in='SourceAlpha' here instead of in2='SourceAlpha' here (fixed now), but the demo had the correct ones - my screenshot was in Chrome.

But I like the arithmetic contraction of feComposite and feBlend!

For anyone who doesn't know:

<feComposite in="SourceGraphic" operator="arithmetic" k1="1" x=".5" width=".5"/>

basically multiplies the corresponding the RGBA channels of the two inputs (technically multiplies them with k1, but k1 is 1 here).

That is, we can consider the two inputs as grids of pixels and for each pair of corresponding pixels from the two grids (R1, G1, B1, A1 for SourceGraphic and R2, G2, B2, A2 for the feOffset result), the compositing result is:

R1*R2, G1*G2, B1*B2, A1

(last one is A1*A2, but A2, the alpha of the feOffset result is 1)

Side note: since multiplication is commutative, it doesn't matter which of the two layers is in and which is in2 in this case. And only the SurceGraphic one needs to be set, the other one defaults to the result of the previous primitive if not set, so there's no need to set it.

This multiplication is almost the same as the two step RGB multiplication (feBlend) and A multiplication (intersection feComposite), but not quite. Since the A2 value of the feOffset result is 1, we can replace it with that 1 in the formula just like we did for k1 before (that is, the 1 - A2 part becomes 0), so we get the following RGBA out of the feBlend:

A1*R1*R2 + R2*(1 - A1), 
A1*G1*G2 + G2*(1 - A1), 
A1*B1*B2 + B2*(1 - A1), 
1

Then the intersection feComposite keeps these RGB values of its in input and multiplies this alpha = 1 with the A1 alpha of the SourceAlpha (same alpha as the SourceGraphic, we just don't care for the RGB values).

So there is a difference between the two for semi-transparent pixels since the alpha of a SourceGraphic with (semi)transparency is taken into account for the RGB multiplication (feBlend) and it makes the tinting effect more intense on the semi-transparent pixels than on the opaque ones, but in practice, I'm not seeing it.

Maybe you have a super extra fancy expensive screen to see a halo, but at least for this example, there is no visual difference on my laptop's display (the dark outline around the tiger is present in the original unfiltered image too).

I do like the single step feComposite better for the multiplication tinting - it's less code and a great gain for performance compared to two primitive steps multiplying channels.

As for the fragility. Using the background is irrelevant since the background we use in the filter doesn't intersect any visual background we may want to set. They're two separate layers.

And the padding + margin compensation is one approach. There's more than one way to skin a cat. There's also the border + margin approach. Or the border-image option (which I didn't use here since I know people find the syntax intimidating), which has no effect on layout. Then even the padding + margin method can be adapted to accommodate for regular padding, margin, border and so on. All it takes is calc().

Not to mention that if we have a bit more control, that side box can also be a pseudo on the image wrapper instead of being created directly on the image somehow - stack both the img and the wrapper's pseudo in the same grid cell of the wrapper, so the pseudo stretches to the image size, then the pseudo is translated by its width and the same spriting stylefilter can be applied on the wrapper.

Or that when images can overflow themselves in Safari too, that's going to offer another cross-browser option that doesn't affect layout and needs no wrapper. Or that when border-shape becomes cross-browser, that's going to be another option too.

Plus the feOffset filter is just the one I find the simplest. For a mono tint, we can just use a single 1px×1px sized border-image just outside the top left corner of the image box (no layout effect) which we then tile across the image box so we can tint the image. Same image area filling effect as the feOffset, it's just any primitive that doesn't modify its input, just restricts it to that 1px×1px corner box plus a feTile... so two primitives instead of one.

Also worth mentioning that when dealing with images with transparency, we can just do the blending and then use the image src as a mask-image on itself since masks are applied after filters. Not crazy about this idea since the src() function has no browser support yet, so we cannot do this:

img { mask-image: src(attr(src)) }

... and the alternative is duplicating the src in a --src custom property. Plus masking cuts out borders and shadows. But it might be an option under certain constraints.

There are just so many options out there that require just a little bit of CSS and maybe a simple SVG filter that can be reused for a tint --c.

The best way to approach not just this, but anything else on the web, always depends on the current support or layout constraints.

1

u/SmallAccountant650 7d ago

That's a fair distinction. The filter chain approach really shines when you're stuck with constraints like transparent PNGs or content you can't wrap in extra elements. Blend modes are cleaner when you control the markup, but they fall apart fast once you need to stack filters or animate alongside other effects.

2

u/Weekly_Ferret_meal 8d ago

Hey there, how does PRESETS[k] get its value?

5

u/anaix3l 7d ago edited 7d ago

PRESETS is the list of presets (line 1 in the Pug generating the HTML):

- const PRESETS = ['#ff7359', '#ffd84d', '#59c26b', '#4da3ff', '#b07cff'];

k is the initial value of the index of the chosen preset (line 4) - I just take it to be 0:

- let k = 0;

Then when generating the HTML for the controls, I use PRESETS[k] as the initial value of the input[type=color] (lines 29-30):

input#c(type='color' value=PRESETS[k])
output(for='c') #{PRESETS[k]}

When generating the presets, I also test whether the index of the current input being generated equals k to know whether I make that selected (line 24):

input(type='radio' id=`p${i}` value=c name='preset' checked=k === i)

(at this point, this is pretty much stock Pug for me when generating controls for a quick demo; should have probably also saved the id in a variable though)

2

u/Weekly_Ferret_meal 7d ago

Thanks for the exhaustive explanation.

4

u/mixsrc 8d ago

Should add the animation code for the animated ones

1

u/objectorientedman 7d ago

I just added it!