Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Fri, 31 Oct 2025 16:06:51 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Super Simple Full-Bleed & Breakout Styles https://frontendmasters.com/blog/super-simple-full-bleed-breakout-styles/ https://frontendmasters.com/blog/super-simple-full-bleed-breakout-styles/#comments Fri, 31 Oct 2025 16:06:50 +0000 https://frontendmasters.com/blog/?p=7560 Recently, I saw someone asked on Reddit what others are using these days for full-bleed and breakout elements. This refers to having a main content area of limited width (usually centered), but having the ability for some elements to be wider, either all the way to the browser edges or somewhere in-between.

desired layout at various viewports — notice the image is a full-bleed element, the warning is a breakout element and the header is a  breakout element with a full-bleed background

Is it still the old method that involves stretching elements to 100vw and then moving them in the negative direction of the x axis via an offset, margin, or translation?

Or is it the newer method that involves a grid with a limited width main column in the middle then symmetrical columns on the sides, with elements spanning an odd number of columns that depends on whether we want them to have the normal width of the main column or we want them a bit wider, breaking out of that or we even want them to be full-bleed?

There is no perfectly right answer. It depends on use case and how you look at it. We’re going to look at modified and combined versions and essentially achieve what we need to depending on the situation with modern CSS.

The old method described in the 2016 CSS-Tricks article has the disadvantage of relying on a Firefox bug (that has been fixed since 2017) to work well in all situations. The problem is that 100vw doesn’t take into account any vertical scrollbars we might have (and no, the new viewport units don’t solve that problem either). This leads to the 100vw width elements being wider than the available horizontal space if there is a vertical scrollbar, overflowing and causing a horizontal scrollbar, something I also often see with the bizarre practice of setting the width of the body to 100vw. Now, considering the elements we normally want to be full-bleed are likely images, we can hide the problem with overflow-x: hidden on the html. But it still doesn’t feel quite right.

Maybe it’s because I’m a tech, not a designer who thinks in terms of design grids, but I prefer to keep my grids minimal and when I look at the desired result, my first thought is: that’s a single column grid with the items that are wider than the column, and everything is center-aligned.

So let’s take a look at the approach I most commonly use (or at least start from), which doesn’t involve a scary-looking grid column setup, and, for the simple base cases, doesn’t involve any containers or even any calc(), which some people find confusing.

The Base Grid

We’re starting off with a grid, of course! We set a one limited width column grid on the body and we middle align this grid horizontally within the the content-box of the body:

body {
  display: grid;
  grid-template-columns: min(100%, 60em);
  justify-content: center
}

By default, display: grid creates a one column grid that stretches horizontally across the entire content-box width of the element it’s set on. This makes all the children of the element getting display: grid be distributed in that one column, one on each row. The first on the first row, the second on the second row and so on.

The grid-template-columns property is used here to max out the width of this one column at 60em by setting its width to be the minimum between 100% of the content-box width and 60em. If the content-box of the element we’ve set the grid on has a width of up to 60em, then the one column of the grid stretches horizontally across the entire content-box. If the content-box of the element we’ve set the grid on has a width above 60em, then our one grid column doesn’t stretch horizontally across the entire content-box anymore, but instead stays 60em wide, the maximum width it can take. Of course, this maximum width can be any other value we want.

The justify-content property is used to align the grid horizontally within the content-box of the element it’s set on. In this case, our one grid column is center aligned.

Note that I keep talking about the content-box here. This is because, even at really narrow viewports, we normally want a bit of space in between the text edge and the lateral edge of the available area (the viewport minus any scrollbars we might have). Initially, this space is the default margin of 8px on the body, though I also often do something similar to the approach Chris wrote about recently and zero the default margin to replace it with a clamped font-relative padding. But whichever of them is used still gets subtracted from the available space (viewport width minus any vertical scrollbar we might have) to give us the content-box width of the body.

Now whatever children the body may have (headings, paragraphs, images and so on), they’re all in the limited width grid cells of our one column, something that’s highlighted by the DevTools grid overlay in the screenshot below.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay.
the one limited width column grid layout with the DevTools grid lines overlay (live demo)

Full-Bleed Elements

Let’s say we want to make an element full-bleed (edge to edge). For example, an image or an image gallery, because that’s what makes the most sense to have stretching all across the entire available page width. This means we want the full viewport width minus any scrollbars we might have.

Nowadays we can get that by making the html a container so that its descendants know its available width (not including scrollbars) as 100cqw (container query width).

html { container-type: inline-size }

Having this, we can create our full-bleed elements:

.full-bleed-elem {
  justify-self: center;
  width: 100cqw
}

Setting width: 100cqw on our full-bleed elements means they get the full available content-box width of the nearest container, which is the html in this case.

The justify-self aligns the element horizontally within its grid-area (which is limited to one grid cell in our case here). We need to set it here because the default is start, which means the left edge of the element starts from the left edge of its containing grid-area. The left edge of the containing grid-area is the same as the left edge of our one column grid here.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On some of these rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have).
one column grid with full-bleed elements and a DevTools grid overlay highlighting the grid lines

Just like before, we still have a single column grid, center aligned.

One thing to note here is this means we cannot have any margin, border or padding on the html element as any of these would reduce its content-box, whose size is what the container query units are based on. In practice, the margin, border, and padding on the html are all zero by default and I don’t think I’ve seen them set to anything else anywhere outside of some mind-bending CSS Battle solutions.

Another thing to note is that there may be cases where we need another container somewhere in between. In that case, we can still access the content-box width of the html as detailed in a previous article:

@property --full-w {
  syntax: '<length>';
  initial-value: 0px;
  inherits: true;
}

html { container-type: inline-size }

body { --full-w: 100cqw }

.full-bleed-elem {
  justify-self: center;
  width: var(--full-w);
}

Often times, we probably also want some padding on the full-bleed element if it is, for example, an image gallery, but not if it is a single img element.

For img elements, the actual image always occupies just the content-box. Any padding we set on it is empty space around the content-box. This is not generally  desirable in our case. Unless we want to add some kind of decorations around it via the background property (by layering CSS gradients to create some kind of cool pattern, for example), we want the image to stretch all across the available viewport space after accounting for any vertical scrollbar we might have and not be left with empty space on the lateral sides.

Furthermore, if the img uses a box-sizing of content-box, that empty padding space gets added to the 100cqw width of its content-box, making the padding-box width exceed the available space and causing a horizontal scrollbar on the page.

When setting a padding on full-bleed elements, it’s probably best to exclude img elements:

.full-bleed-elem:not(img) { padding: .5em }

Note that in this case, the full-bleed elements getting the padding need to also have box-sizing set to border-box. This is done so that the padding gets subtracted out of the set width and not added as it would happen in the default content-box case.

.full-bleed-elem:not(img) {
  box-sizing: border-box;
  padding: .5em
}

You can see it in action and play with it in the following live demo:

You might be wondering… is it even necessary to set border-box since setting everything to border-box is a pretty popular reset style?

Personally, I don’t set that in resets anymore because I find that with the the new layout options we have, the number of cases where I still need to explicitly set dimensions in general and widths in particular has declined. Drastically. Most of the time, I just size columns, rows, set the flex property instead and let the grid or flex children get sized by those without explicitly setting any dimensions. And when I don’t have to set dimensions explicitly, the box-sizing becomes irrelevant and even problematic in some situations. So I just don’t bother with including box-sizing: border-box in the reset these days anymore and instead only set it in the cases where it’s needed.

Like here, for the non-img full bleed elements.

Another thing you may be wondering about… how about just setting a negative lateral margin?

We know the viewport width minus any scrollbars as 100cqw, we know the column width as 100%, so the difference between the two 100cqw - 100% is the space on the left side of the column plus the space on the right side of the column. This means half the difference .5*(100cqw - 100%), which we can also write as 50cqw - 50%, is the space on just one side. And then we put a minus in front and get our lateral margin. Like this:

.full-bleed-elem {
  margin: .5rem calc(50% - 50cqw);
}

Or, if we want to avoid overriding the vertical margin:

.full-bleed-elem {
  margin-inline: calc(50% - 50cqw);
}

This seems like a good option. It’s just one margin property instead of a justify-self and a width one. And it also avoids having to set box-sizing to border-box if we want a padding on our full-bleed element. But we should also take into account what exactly we are most likely to make full-bleed.

One case we considered here was that of full-bleed images. The thing with img elements is that, by default, they don’t size themselves to fit the grid areas containing them, they just use their own intrinsic size. For full-bleed images this means they are either going to not fill the entire available viewport space if their intrinsic width is smaller than the viewport or overflow the viewport if their intrinsic width is bigger than the available viewport space (the viewport width minus any vertical scrollbar we might have). So we need to set their width anyway.

For the other case, that of the scrolling image gallery, the negative margin can be an option.

Breakout Elements

These are wider than our main content, so they break out of our grid column, but are not full-bleed.

So we would give them a width that’s smaller than the content-box width of the html, which we know as 100cqw, but still bigger than the width of our only grid column, which we know as 100%. Assuming we want breakout elements to extend out on each side by 4em, this means:

.break-elem {
  justify-self: center;
  width: min(100cqw, 100% + 2*4em)
}

Again, we might use a negative lateral margin instead. For breakout elements, which are a lot more likely to be text content elements, the negative margin approach makes more sense than for the full-bleed ones. Note that just like the width, the lateral margin also needs to be capped in case the lateral space on the sides of our column drops under 4em.

.break-elem { margin: 0 max(-4em, 50% - 50cqw) }

Note that we use the max() because for negative values like the margin here, the smaller (minimum) one in absolute value (closer to 0) is the one that’s bigger when looking at the full axis going from minus to plus infinity.

But then again, we might want to be consistent and set full-bleed and breakout styles the same way, maybe grouping them together:

.full-bleed-elem, .break-elem {
  justify-self: center;
  width: min(100cqw var(--comp-w, ));
}

/* This is valid! */
.break-elem { --comp-w: , 100% + 2*4em  }

:is(.full-bleed-elem, .break-elem):not(img) {
  box-sizing: border-box;
  padding: .5em;
}

Some people prefer :where() instead of :is() for specificity reasons, as :where() always has 0 specificity, while :is() has the specificity of the most specific selector in its arguments. But that is precisely one of my main reasons for using :is() here.

And yes, both having an empty default for a CSS variable and its value starting with a comma is valid. Replacing --comp-w with its value gives us a width of min(100cqw) (which is the same as 100cqw) for full-bleed elements and one of min(100cqw, 100% + 2*4em) for breakout elements.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On some of these rows, we have full-bleed images that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On others, we have breakout boxes that expand laterally outside their grid cells, but are not wide enough to be full-bleed.
one column grid with full-bleed and breakout elements, as well as a DevTools grid overlay highlighting the grid lines (live demo)

If we want to have different types of breakout elements that extend out more or less, not all exactly by the same fixed value, we make that value a custom property --dx, which we can change based on the type of breakout element:

.break-elem { --comp-w: , 100% + 2*var(--dx, 4em) }

The --dx value could also be negative and, in this case, the element doesn’t really break out of the main column, it shrinks so it’s narrower.

.break-elem--mini { --dx: -2em }
.break-elem--maxi { --dx: 8em }
Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. One of these rows has a full-bleed image that expands all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed. Most of these boxes are wider than their containing grid cells, but one is narrower.
one column grid with a full-bleed image and various sizes of breakout elements, as well as a DevTools grid overlay highlighting the grid lines (live demo)

Full-Bleed Backgrounds for Limited Width Elements

Sometimes we may want only the background of the element to be full-bleed, but not the element content. In the simplest case, we can do with a border-image and if you want to better understand this property, check out this article by Temani Afif detailing a lot of use cases.

.full-bleed-back {
  border-image: var(--img) fill 0/ / 0 50cqw;
}

This works for mono backgrounds (like the one created for the full-bleed header and footer below with a single stop gradient), for most gradients and even for actual images in some cases.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed mono background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a full-bleed mono background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

The mono background above is created as follows (all these demos adapt to user theme preferences):

--img: conic-gradient(light-dark(#ededed, #121212) 0 0)

This method is perfect for such mono backgrounds, but if we want gradient or image ones, there are some aspects we need to consider.

The thing about the 0 50cqw outset value is that it tells the browser to extend the area where the border-image is painted by 50cqw outwards from the padding-box boundary on the lateral sides. This means it extends outside the vewport, but since this is just the border-image, not the border reserving space, it doesn’t cause overflow/ a horizontal scrollbar, so we can keep it simple and use it like this for gradients.

That is, if we can avoid percentage position trouble. While this is not an issue in linear top to bottom gradients, if we want to use percentages in linear left to right gradients or to position radial or conic ones, we need to scale the [0%, 100%] interval to the [50% - 50cqw, 50% + 50cqw] interval along the x axis.

.linear-horizontal {
  --img: 
    linear-gradient(
      90deg, 
      var(--c0) calc(50% - 50cqw), 
      var(--c1) 50%
    );
}

.radial {
  --img: 
    radial-gradient(
      15cqw at calc(50% - 25cqw) 0, 
      var(--c0), 
      var(--c1)
    );
}

.conic {
  --img: 
    conic-gradient(
      at calc(50% + 15cqw), 
      var(--c1) 30%, 
      var(--c0), 
      var(--c1) 70%
    );
}

However, this scaling is not enough for linear gradients at an angle that’s not a multiple of 90°. And it may be overly complicated even for the types of gradients where it works well.

So another option is compute how much the border-image needs to expand laterally out of the available horizontal space 100cqw and the maximum grid column width --grid-w. This then allows us to use percentages normally inside any kind of gradient, including linear ones at an angle that’s not a multiple of 90°.

body {
  --grid-w: 60em;
  display: grid;
  grid-template-columns: min(100%, var(--grid-w));
  justify-content: center;
}

.full-bleed-back {
  border-image: 
    var(--img) fill 0/ / 
    0 calc(50cqw - .5*var(--grid-w));
}
Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed gradient background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a full-bleed angled gradient background (at an angle that’s not a multiple of 90°); it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

This has a tiny problem that other styling decisions we’re likely to take (and which we’ll discuss in a moment) prevent from happening, but, assuming we don’t make those choices, let’s take a look at it and how we can solve it.

full-bleed background issue on narrow viewports

On narrow viewports, our background isn’t full-bleed anymore, it stops a tiny distance away from the lateral sides. That tiny distance is at most the size of the lateral margin or padding on the body. As mentioned before, I prefer to zero the default margin and use a font-size-relative padding, but in a lot of cases, it doesn’t make any difference whatsoever.

Screenshot collage. Shows the top area of the page with the header in both the dark and light theme cases at a narrow viewport width of 400px. It also highlights the fact that the header's full-bleed background isn't quite full-bleed, but stops a tiny distance away from the lateral sides.
the problem in the narrow viewport case, highlighted for both the dark and the light themes

This happens when the maximum grid column width --grid-w doesn’t fit anymore in the available viewport space (not including the scrollbar) minus the lateral spacing on the sides of our one column grid (set as a margin or padding).

The solution is to use a max() instead of the calc() to ensure that the border-image expands laterally at the very least as much as that lateral spacing --grid-s.

body {
  --grid-w: 60em;
  --grid-s: .5em;
  display: grid;
  grid-template-columns: min(100%, var(--grid-w));
  justify-content: center;
  padding: 0 var(--grid-s);
}

.full-bleed-back {
  border-image: 
    var(--img) fill 0/ / 
    0 max(var(--grid-s), 50cqw - .5*var(--grid-w));
}
fix for full-bleed background issue on narrow viewports (live demo)

For actual images however, we have an even bigger problem: border-image doesn’t offer the cover option we have for backgrounds or images and we don’t really have a reliable way of getting around this. One of the repeat options might work for us in some scenarios, but I find that’s rarely the case for the results I want in such situations.

You can see the problem in this demo when resizing the viewport — for an element whose height is unknown as it depends on its content, the border-image option (the second one) means that if we want to avoid the image getting distorted, then its size needs to be intrinsic size. Always. It never scales, which means it repeats for large viewports and its sides get clipped off for small viewports.

So if we want more control over an image background or multiple background layers, it’s probably better to use an absolutely positioned pseudo-element. This also avoids the earlier problem of the full-bleed background not going all the way to the edges without taking into account the lateral spacing on the grid container (in this case, the body).

.full-bleed-back-xtra {
  position: relative;
  z-index: 1
}

.full-bleed-back-xtra::before {
  position: absolute;
  inset: 0 calc(50% - 50cqw);
  z-index: -1;
  content: ''
}

The inset makes our pseudo to stretch across the entire padding-box of its parent vertically and outside of it (minus sign) by half the available viewport space (viewport width minus any scrollbars) minus half the pseudo parent’s width.

The negative z-index on the pseudo ensures it’s behind the element’s text content. The positive z-index on the element itself ensures the pseudo doesn’t end up behind the grid container’s background too.

The pseudo background can now be a cover image:

background: var(--img-pos, var(--img) 50%)/ cover

I’m taking this approach here to allow easily overriding the background-position together with each image if necessary. In such a case, we set --img-pos:

--img-pos: url(my-back-img.jpg) 35% 65%

Otherwise, we only set --img and the default of 50% gets used:

--img-pos: url(my-back-img.jpg)

In the particular case of our demos so far, which use a light or dark theme to respect user preferences, we’ve also set a light-dark() value for the background-color, as well as an overlay blend mode to either brighten or darken our full-bleed background depending on the theme. This ensures the header text  remains readable in both scenarios.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed image background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a full-bleed image background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

We can also have multiple layers of gradients, maybe even blended, maybe even with a filter making them grainy (something that would help with the visible banding noticed in the border-image method examples) or creating a halftone pattern.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a limited width header with a solid full-bleed multi-gradient, filtered background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes that are not the same width as their grid cells, but are not wide enough to be full-bleed.
one column grid that has a tightly fit limited width header with a filtered full-bleed multi-layer background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

Combining options

We can of course also have a breakout element with a full-bleed background – in this case, we give it both classes, break-elem and full-bleed-back.

Our recipe page header for example, probably looks better as a breakout element in addition to having a full-bleed background.

If the breakout elements in general have a border or their own specific background, we should ensure these don’t apply if they also have full-bleed backgrounds:

.break-elem:not([class*='full-bleed-back']) {
  border: solid 1px;
  background: var(--break-back)
}

Or we can opt to separate these visual prettifying styles from the layout ones. For example, in the Halloween example demos, I’ve opted to set the border and background styles via a separate class .box:

.box {
  border: solid 1px var(--c);
  background: lch(from var(--c) l c h/ .15)
}

And then set --c (as well as the warning icon in front) via a .box--warn class.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. On the very first row, we have a breakout header (wider than its containing grid cell, but not wide enough to be full-bleed) with a solid full-bleed multi-gradient, filtered background. On other rows, we have full-bleed elements that expand all across the entire available page width (the viewport width minus any vertical scrollbars we might have). On other rows, we have breakout boxes.
one column grid that has a breakout header with a filtered full-bleed multi-layer background; it also has a full-bleed image and a breakout element, as well as a DevTools grid overlay highlighting the grid lines (live demo)

Another thing to note here is that when having a full-bleed background for a breakout element and we use the border-image tactic, we don’t have to adapt our formula to take into account the lateral spacing, as that’s set as a padding on the breakout element and not on its grid parent.

The most important of these techniques can also be seen in the meta demo below, which has the relevant CSS in style elements that got display: block.

Nesting

We may also have a figure whose img is full-bleed, while the figcaption uses the normal column width (or maybe it’s a breakout element).

<figure>
  <img src='full-bleed-img.jpg' alt='image description' class='full-bleed-elem'>
  <figcaption>image caption</figcaption>
</figure>

Not much extra code is required here.

The simple modern solution is to make the img a block element so that the justify-self property set via the .full-bleed-elem middle aligns it even if it’s not a grid or flex item.

img.full-bleed-elem { display: block }

However, support for justify-self applying to block elements as per the current spec is still limited to only Chromium browsers at the moment. And while the Firefox bug seems to have had some activity lately, the Safari one looks like it’s dormant.

So the easy cross-browser way to get around that without any further computations is to make the figure a grid too in this case.

figure:has(.full-bleed-elem, .break-elem) {
  display: grid;
  grid-template-columns: 100%;
  width: 100%;
}
Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. This grid has a figure that is tightly fit inside its grid cell, but also has a full-bleed image spreading across the entire available horizontal space (the viewport width minus any vertical scrollbars) we might have. On other rows, we have full-bleed elements or breakout boxes (wider than their containing grid cells, but still not wide enough to be full-bleed on wide screens). We also have a combination that's a breakout header with a full-bleed background.
one column grid that has a figure, tightly fit horizontally within its containing column, but with a full-bleed image; there’s also a DevTools grid overlay highlighting the grid lines (live demo)

Floating Problems

This is a problem that got mentioned for the three column grid technique and I really didn’t understand it at first.

I started playing with CSS to change the look of a blog and for some reason, maybe because that was what the first example I saw looked like, I got into the habit of putting any floated thumbnail and the text next to it into a wrapper. And it never occurred to me that the wrapper wasn’t necessary until I started writing this article and looked into it.

Mostly because… I almost never need to float things. I did it for those blog post thumbnails fifteen years ago, for shape-outside demos, for drop caps, but that was about it. As far as layouts go, I just used position: absolute for years before going straight to flex and grid.

This was why I didn’t understand this problem at first. I thought that if you want to float something, you have to put it in a wrapper anyway. And at the end of the day, this is the easiest solution: put the entire content of our one column in a wrapper. In which case, until justify-self applying on block elements works cross-browser, we need to replace that declaration on full-bleed and breakout elements with our old friend margin-left:

margin-left: calc(50% -50cqw)

This allows us to have floated elements inside the wrapper.

Screenshot. Shows a middle aligned grid with a single column and multiple rows, something that's highlighted by the DevTools-enabled grid overlay. This grid has a single grid child that is tightly fit inside its containing column and acts as a wrapper for full-bleed elements, breakout boxes (wider than their containing grid cells, but still not wide enough to be full-bleed on wide screens), combinations of these like a breakout header with a full-bleed background. But this wrapper also allows its children to be floated.
one column grid that has a single grid child, tightly fit horizontally within its containing column and acting as a wrapper for the entire page content; since this wrapper has no flex or grid layout, its children can be floated (live demo)

Final Thoughts: Do we even really need grid?

At this point, getting to this floats solution begs the question: do we even really need grid?

It depends.

We could just set lateral padding or margin on the body instead.

I’d normally prefer padding in this case, as padding doesn’t restrict the background and sometimes we want some full viewport backdrop effects involving both the body and the html background.

Other times, we may want a background just for the limited width of the content in the middle, in which case margin on the body makes more sense.

If we want to be ready for both situations, then we’re better off with not setting any margin or padding on the body and just wrapping all content in a limited width, middle aligned (good old max-width plus auto margins) main that also gets a background.

At the same time, my uses cases for something like this have never involved using floats and have benefitted from other grid features like gaps, which make handling spacing easier than via margins or paddings.

So at the end of the day, the best solution is going to depend on the context.

]]>
https://frontendmasters.com/blog/super-simple-full-bleed-breakout-styles/feed/ 1 7560
Inset Shadows Directly on img Elements (Part 1) https://frontendmasters.com/blog/inset-shadows-directly-on-img-elements-part-1/ https://frontendmasters.com/blog/inset-shadows-directly-on-img-elements-part-1/#respond Wed, 01 Oct 2025 15:59:05 +0000 https://frontendmasters.com/blog/?p=7213 You might think the job of putting an inset shadow on an <img> is trivial: just set a box-shadow like inset 0 1px 3px and that’s it!

You’d be wrong.

This doesn’t work because the actual image is content for the img element. And content is painted on top of box-shadow.

This problem is something that has been the topic of countless questions on Stack Overflow as well as Reddit and other places on the internet. It has also been covered in many articles over the past 15 years. Now in 2025, it still made the list of pain points when dealing with shapes & graphics according to the State of CSS survey.

So why yet another article? Well, almost all the solutions I’ve seen so far involve at least another element stacked on top of the img (assuming they don’t straight up replace the img with a div), so that we can have a “cover” with the exact dimensions on top – this is the (pseudo)element that actually gets the inset shadow. Beyond using at the very least an extra pseudo-element for each image, this can be annoying for users, as the right click img menu is lost unless the “cover” gets pointer-events: none.

I want to show you a solution that allows us to add the shadow directly on <img> elements without requiring an extra wrapper or sibling for each.

This article is going to have two parts, the first (current one) going into a lot of detail about the how behind creating the basic inset black shadow with offsets, blur and spread radii and the second being a deep dive into pain points like painting the shadow and limitations tied to length values.

Base setup

We have just an img element:

<img src='my-image.jpg' alt='image description'>

And a simple SVG filter:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
  </filter>
</svg>

Wait, don’t run away screaming!

I promise that, while SVG filters may seem scary and this technique has some limitations and quirks, it’s still easy to digest when going through it step by step, each step having interactive demos to help with understanding how things work in the back. By the end of it, you’ll have a bunch of cool new tricks to add to your web dev bag.

So let’s get started!

First off, our SVG filter needs to be inside an svg element. Since this element only exists to contain our filter, it is not used to display any graphics, it is functionally the same as a style element. So we zero its dimensions, hide it from screen readers and take it out of the document flow from the CSS:

svg[height='0'][aria-hidden='true'] { position: fixed }

We then apply our filter on the img element:

img { filter: url(#si) }

Note that the filter as it is at this point causes the img to disappear in Firefox, even as it leaves it unchanged in Chrome. And, according to the spec, an empty filter element means the element the filter applies to does not get rendered. So Firefox is following the spec here, even if the Chrome result is what I would have expected: an empty filter being equivalent to no filter applied.

The base filter content

Offset the alpha map

We start off by offsetting the alpha map of the filter input, the filter input being our img in this case. The alpha map is basically the filter input where every single pixel has its RGB channels zeroed and its alpha channel preserved.

Since here the filter input is a plain rectangular, fully opaque image, the alpha map (referenced within the SVG filter as SourceAlpha) is a fully opaque black rectangle within the boundary of our initial image, while everything around it is fully transparent. Note that if the img has a border-radius (with any kind of corner-shape), then the alpha map is going to respect that too.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
  </filter>
</svg>

These fe-prefixed elements inside our filter (“fe” stands for “filter effect”) are called filter primitives. They may have zero, one, or two inputs. Primitives with zero inputs create a layer based on their other attributes (for example, feTurbulence can give us a noise layer based on a baseFrequency attribute). Primitives with one input (like feOffset here) modify that input. And finally, primitives with two inputs combine them into one result (for example, feBlend blends its two inputs using the blend mode given by its mode attribute).

All of those needed for the base filter creating a simple inset black shadow have either one or two, though when we get to painting the shadow and other effects, we may need to use some with no inputs.

For most of those with a single input, we don’t specify that input explicitly (by setting the in attribute) because we’re using the defaults! Filter primitive inputs are by default the result of the previous primitive or, in the case of the very first primitive, the filter input (referenced within the SVG filter as SourceGraphic).

feOffset in particular offsets its input along the x and/or y axis. In our particular case, it offsets its input by 9px along the x axis and by 13px along the y axis.

The following interactive demo illustrates how this primitive works and allows changing the feOffset attributes to see how that affects the visual result.

Note that the in attribute and the offset ones (dx and dy) are greyed and crossed out when set to SourceGraphic and 0 respectively. It’s because these are the default values and if they are the values we want for them, then we don’t need to set them at all.

Blur the offset map

Next, we blur this offset result.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
  </filter>
</svg>

Adding this second primitive is equivalent to chaining blur(5px) after the filter we had at the previous step (with only the feOffset primitive).

Note that this blur radius (and any SVG blur radius in general, whether it’s a stdDeviation attribute of an SVG filter primitive or a blur radius used by CSS equivalents like the blur() or drop-shadow() functions) needs to be half the one we’d use for a box-shadow if we want the same result. You can check out this article by David Baron for a detailed explanation of the why behind.

The interactive demo below lets us play with the filter we have so far (all primitive attributes can be changed) in order to get a better feel for how it works.

Note that these first two primitives can be in any order (we get the exact same result if we apply the offset after the blur). However, this is generally not the case — in most cases, the order of the primitives does matter.

Also note that in some scenarios (for example if we increase the blur radius to the maximum allowed by the demo), the blur seems cut off from a certain point outside the input element’s boundary. This cutoff is where the filter region ends.

Screenshot of the previous interactive demo where the interactive code panel can control the the visual result, in the case when the stdDeviation value of the feGaussianBlur primitive is bumped up to 32. In this situation, the blur of the black rectangle doesn't fade to full transparency. Instead, it gets abruptly cut off not far outside the initial boundary of the filter input image.

By default, the filter region extends 10% of the filter input’s bounding box size in every direction. In the case of a rectangular image, the bounding box is the image rectangle, the one whose boundary is marked by a dashed line in the interactive demos above.

We can change this region by changing the x, y, width and height attributes of the filter element. By default, these are given relative to the width and height of the filter input’s bounding box, using either a percentage or decimal representation. We could change the value of the filterUnits attribute to make them fixed pixel values, but I don’t think that’s something I’ve ever wanted to do and the default of them being relative to the filter input’s bounding box is what we want here, too.

For example, x='-.25'and x='-25%' are both valid and produce the same result. In this case, the filter region starts from 25% of the input bounding box width to the left (negative direction) of the left edge of this bounding box. The interactive demo below allows toying with the filter region too.

However, since our desired effect, the basic inset shadow, is limited to the area of the filter input (that is, the area of the original image), we don’t care if anything outside it gets cut off by the filter region limit, so we won’t be touching these filter attributes. At least for now, as long as we’re talking just about the base inset shadow.

Subtract offset & blurred version from initial one

The next step is to subtract the alpha of this offset and blurred result from the original alpha map (SourceAlpha) with no offset or blur applied:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
    <feComposite in='SourceAlpha' operator='out'/>
  </filter>
</svg>

feComposite is a primitive with two inputs (in and in2, both defaulting to the result of the previous primitive). When we use feComposite with the operator attribute set to out, we subtract the second input (in2, not set explicitly here as we want it to be the result of the previous primitive) out of the first one (in).

This isn’t plain clamped subtraction. Instead, it’s similar to what subtract (source-out in the ancient, non-standard WebKit version) does when compositing alpha mask layers: the alpha of the first input in (α) is multiplied with the complement of the second input in2 alpha (α₂).

This means that for every pair of corresponding pixels from the two inputs, the RGB channels of the result are those of the pixel from the first input (in), while the alpha of the result is given by the following formula:

α·(1 – α₂)

Where α is the alpha of the pixel from the first input in, and α₂ is the alpha of the corresponding pixel from the second input in2, the input we subtract out of the first to get a black inset shadow.

Note that this latest interactive demo disables the option to switch between SourceAlpha and SourceGraphic inputs for the feOffset primitive. This is due to a Firefox bug which we might hit in certain situations and which makes the result of the feComposite simply disappear if feOffset uses the default SourceGraphic input.

Switching the operator also isn’t enabled here, as it would mean just too much to unpack and most is outside the scope of this article anyway. Just know that some of the operator values work exactly the same as their CSS mask-composite equivalents.

For example, over is equivalent to add (source-over in the ancient, non-standard WebKit version), subtracting the alpha product from their sum (α + α₂ - α·α₂).

Then in is equivalent to intersect (source-in), multiplying the alphas of the two inputs (α·α₂).

And xor is equivalent to exclude, where we add up the result of each of the two inputs being subtracted from the other (α·(1 – α₂) + α₂·(1 – α)).

For more details and visual examples illustrating how these operators work, you can check out this page (note that all operator values used for feComposite are source-* ones, for the effect given by the destination-* ones, we need to reverse the two inputs).

Place the initial image underneath

Now that we have the shadow, all we still need to do is place the filter input (the image in our case) underneath it. I’ve often seen this done with feMerge or feComposite. I personally prefer to do it with feBlend as this primitive with the default mode of normal produces the exact same result as the other two. Plus, other modes may offer us even more visually interesting results.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feOffset in='SourceAlpha' dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
    <feComposite in='SourceAlpha' operator='out'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Just like feComposite, feBlend takes two inputs. in is the one on top and we don’t need to set it explicitly here, as it defaults to the result of the previous primitive, the inset shadow in our case. This is exactly the layer we want to have on top here. in2 is the one at the bottom and we set it to the filter input (SourceGraphic), which is the image in our case.

A base case example

This is exactly the technique we used to create the inner shadows on these squircle-shaped images.

A grid of squircle-shaped images with inner shadows.
squircle-shaped images with inset shadows (live demo)

Note that the squircle shape seems to be incorrect in Safari (tested via Epiphany on Ubuntu), but the relevant part (the inset shadow) seems to work well everywhere. Also, nowadays, this is not the simplest way to create squircle shapes anymore with the corner-shape property as well as the shape() function making their way into browsers, but it’s still a way to do it and, leaving aside bugs like the incorrect squircle Safari issue, a better supported one.

Spread it out: how to get a spread radius this way

The box-shadow property also allows us to control a fourth length value beside the offsets and the blur radius: the spread radius. To get the same effect with an SVG filter, we either erode or dilate the alpha map (SourceAlpha) using feMorphology.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feMorphology in='SourceAlpha' operator='dilate' radius='5'/>
  </filter>
</svg>

When using the dilate operator, what feMorphology does is the following: for every channel of every pixel, it takes the maximum of all the values of that channel for the pixels lying within the specified radius (from the current pixel) along both the x and the y axes in both the negative and positive direction.

Below, you can see how this works for a channel whose values are either maxed out (1) or zeroed (0). For every pixel of the input (green outline around the current one), the corresponding output value for the same channel is the maximum of all the values for that channel within a radius of 1 from the current pixel (within the red square).

how dilation works in the general case

In our case, things are made simpler by the fact that the input of this primitive is the alpha map of the filter input (SourceAlpha). Each and every single one of its pixels has the RGB values zeroed, so it basically doesn’t change anything on the RGB channels. The only thing that changes is the alpha channel at the edges, which is again made simpler by the fact that our input is a rectangular box (the alpha is 1 within the rectangle boundary and 0 outside), so for a radius of 1, our black box grows by 1px in every one of the four directions (top, right, bottom, left), for a radius of 2 it grows by 2px in every direction and so on.

When using the erode operator, feMorphology takes the minimum of all the values of each channel for the pixels lying within the specified radius (from the current pixel) along both the x and the y axes in both the negative and positive direction.

Below, you can see a similar recording to the dilation one, only this time it’s for erosion. For every pixel of the input (green outline around the current one), the corresponding output value for the same channel is the minimum of all the values for that channel within a radius of 1 from the current pixel (within the red square).

how erosion works in the general case

So in our particular case, erosion means that for a radius of 1, our black box shrinks by 1px in every one of the four directions (top, right, bottom, left), for a radius of 2 it shrinks by 2px in every direction and so on.

Just for fun, the interactive demo above allows switching between SourceAlpha and SourceGraphic. This is completely irrelevant in the context of this article, but it was a little low effort extra that allows seeing the effect of this primitive on the RGB channels too.

Since erode is a min() result where the lowest channel value wins, this operation darkens our input. Since dilate is a max() result where the highest channel value wins, this operation brightens our input. Also, they both create squarish shapes, which makes sense given the how behind, illustrated in the videos above. Basically, in the dilate case, every pixel brighter than those around it expands into a bigger and bigger square the more the radius increases; and in the erode case, every pixel darker than those around it expands into a bigger and bigger square as the radius increases.

So if we introduce this feMorphology primitive before all the others in our inset shadow filter (keep in mind this also means removing the in='SourceAlpha‘ attribute from feOffset, as we want the feOffset input to be the feMorphology result and, if we don’t explicitly set the in attribute, it defaults to the result of the previous primitive), it’s going to allow us to emulate the spread radius CSS provides for box-shadow.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='si'>
    <feMorphology in='SourceAlpha' operator='dilate' radius='3'/>
    <feOffset dx='9' dy='13'/>
    <feGaussianBlur stdDeviation='5'/>
    <feComposite in='SourceAlpha' operator='out'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Note that here we may also change the order of the feMorphology and feOffset primitives before the feGaussianBlur one and still get the same result, just like we may also change the order of the feOffset and feGaussianBlur primitives after the feMorphology one. However, the feMorphology primitive needs to be before the feGaussianBlur one, as having the feGaussianBlur primitive before the feMorphology one would give us a different result from what we want.

Unlike the CSS spread radius used by box-shadow, the radius attribute can only be positive here, so the operator value makes the difference, each of the two giving us a result that’s equivalent to either a positive CSS spread radius or a negative one.

Since the dilated/eroded, offset and blurred alpha map is subtracted (minus sign) out of the initial one for an inset shadow, the erode case corresponds to a positive spread radius, while the dilate case corresponds to a negative one.

If we were to use a similar technique for an outer shadow, where the dilated/eroded, offset and blurred alpha map would be the shadow itself, wouldn’t be subtracted out of anything (so plus sign in this case), the erode case would correspond to a negative spread radius and the dilate case to a positive one.

A fancier example

We can take it one step further and not only have an inner shadow with a spread, but also add a little touch that isn’t possible on any element with CSS alone: noise! This is done by displacing the inset shadow using a noise map, similar to how we create grainy gradients.

A grid of squircle-shaped images with grainy inner shadows that also have a spread.
squircle-shaped images with inset shadows with spread and grain (live demo)

]]>
https://frontendmasters.com/blog/inset-shadows-directly-on-img-elements-part-1/feed/ 0 7213
Obsessing Over Smooth radial-gradient() Disc Edges https://frontendmasters.com/blog/obsessing-over-smooth-radial-gradient-disc-edges/ https://frontendmasters.com/blog/obsessing-over-smooth-radial-gradient-disc-edges/#comments Wed, 20 Aug 2025 15:40:49 +0000 https://frontendmasters.com/blog/?p=6737 (… and how that lead me to a very underused CSS feature, resolution media queries.)

You may have come across this situation: you want to create a disc (oval) shape contained within your element’s boundaries, and you want it to have smooth edges. Not jagged; not blurry.

If you want to avoid using a pseudo-element or, even worse, children just for decorative purposes, then radial-gradient() seems to be the best solution. Especially in the case where you might need a bunch of such discs, more than the two pseudos available on an element.

The jaggies problem

However, if we do something like this:

radial-gradient(var(--r), var(--c) 100%, #0000)

Where r is the gradient disc radius, then we get jaggies, a step-like effect along the radial-gradient() disc, whereas one created with a pseudo-element has smooth-looking edges!

Note that we aren’t setting a stop position explicitly for the final stop because the stop position of the final stop defaults to 100% (of the radial-gradient() radius, which is r here), which is what we want in this case anyway. If you need a refresher on radial-gradient(), check out this detailed explainer by Patrick Brosset.

You can see the difference between a pseudo-element disc (smooth edges) and a radial-gradient() one (jaggies) in this live demo:

The smooth-looking edges of the pseudo-element version are a result of anti-aliasing, as it can be seen from the screen recording below:

Animated GIF. Shows the pseudo disc at the top and the radial-gradient() one at the bottom. Zooming in at pixel level in the edge area for both shows us we have a sharp transition from our brick red to transparent in the radial-gradient() case. However, in the pseudo case, anti-aliasing means we have semi-transparent pixels smoothing the transition from brick red to transparent.
recording of zooming in at the disc edges for the two cases

A solution I often see used to try to fix radial-gradient() discs is introducing a 1% distance between the positions of the two stops, something like this.

radial-gradient(var(--r), var(--c) 99%, #0000)

As I mentioned before, unless another value is explicitly specified, the final stop position defaults to 100%, so there’s never any need to explicitly set it to that value since it’s the default.

However, a 1% distance means blurry edges for big discs…

Screenshot. Shows a big reddish disc with slightly blurry edges.
a big disc with a 1% distance between the red and transparent stop positions has blurry edges

… while we still get jaggies for small discs!

Screenshot. Shows a small reddish disc with slightly jagged edges.
a small disc with a 1% distance between the red and transparent stop positions has jagged edges

A solution I thought was bulletproof

So my solution, which, up until recently, I thought would never fail, was to have a 1px distance between the positions of our two stops:

radial-gradient(var(--r), var(--c) calc(100% - 1px), #0000)

This works well regardless of disc size… until it doesn’t!

A pixel is not always a pixel

So there are situations when my “bulletproof” solution fails. For example, in two cases I’ve never really considered before, since my main laptop is almost two decades old: with a hi-DPI display or with “those pesky users doing their nasty zooms” (credit for this gem).

In this case, when we zoom in up to a zoom level of 500%, we get again blurry edges…

Screenshot. Shows a big red disc with slightly blurry edges. The zoom level of 500% is also shown in the top right corner.
a zoomed in page with a fully contained disc with a 1px distance between the red and transparent stop positions – this disc has blurry edges due to the zoom

… and when we zoom out up to a zoom level of 25%, we get jagged edges!

Screenshot. Shows a big red disc with slightly jagged edges. The zoom level of 25% is also shown in the top right corner.
a zoomed out page with a fully contained disc with a 1px distance between the red and transparent stop positions – this disc has jagged edges due to the zoom

Boo!

So what can we do in this case?

Underrated CSS feature: resolution!

Up until this summer, when I got fixated on this zoom problem, I had no idea that CSS provides resolution media queries! These allow us to style things differently based on the device pixel density or zoom level.

I don’t think I have access to any device with a higher pixel ratio display, but I can certainly test zoom. For zoom, this thing really works! For example, if we’re zoomed in to 500%, we’re in the 5x case:

@media (resolution: 5x) {}

This means we can divide that 1px difference by a factor f which we set in the media query.

div {
  background: 
    radial-gradient(var(--r), 
    var(--c) calc(100% - 1px/var(--f, 1)), #0000)
}

@media (resolution: 5x) { div { --f: 5 } }

Note that the x unit is an alias for the dppx unit, an alias that was only added in Level 4 of the CSS Values and Units Module (Level 3 did not include x). However, at this point, I’d say it’s safe to use since all major current desktop and mobile browsers have been supporting it for over half a decade.

I prefer using x as it’s shorter and it feels more intuitive and consistent with picture sources.

We can do the same for all other zoom levels Chromium browsers provide using Sass looping:

$f: .25 .33 .5 .67 .75 .8 .9 1.1 1.25 1.33 1.4 1.5 1.75 2 2.5 3 4 5;

$n: length($f);

@for $i from 0 to $n {
  @media (resolution: nth($f, $i + 1)*1x) {
    div { --f: #{nth($f, $i + 1)} }
  }
}

This gives us a nice pure CSS way of ensuring we have smooth disc edges, not jagged, not blurry, regardless of display resolution or zoom level.

Animated GIF. Shows how zooming from 25% to 500% doesn't affect the radial-gradient() disc edges anymore.
zooming doesn’t mess up the edges of our radial-gradient() disc anymore

Side note: for anyone wondering why the disc starts getting smaller once we’ve increased the zoom above a certain level, this is due to the way we’ve defined the disc radius:

--r: min(50vmin - 2em, 9em);

For large screens/ low zoom levels, the second value in the min() (9em) is the one that’s used, as it’s smaller. Since the default font-size and, consequently, any em value always increases with zoom, the second min() value becomes bigger than the first after a certain level of zoom, so then it’s the first value that gets used. For 50vmin - 2em, 50vmin is always constant, doesn’t depend on the zoom level, but 2em increases with zoom. This means our difference 50vmin - 2em decreases with zoom.

Cool, but that’s quite a lot of media queries and what do we do when other browsers have other zoom levels available instead of the ones in our list above, which is Chromium specific?

For example, Firefox goes from a 50% zoom level to a 30% one, which is the smallest value. It also uses 120%, 170% and 240% zoom values instead of 125%, 175% and 250% respectively in Chrome.

Animated GIF. Same as before, only in Firefox this time, where zoom levels are different.
zoom levels in Firefox are different

This means that since we have no match for a zoom level of 30%, --f remains 1 there, just like in the default case, which means the zoomed out 1px difference is seen as less than a third of that, resulting in jaggies at this smallest Firefox zoom level.

When zooming in, the blur problem is pretty much undetectable for the 120% zoom level (which again has no match among our resolution media queries), but it starts being noticeable for the bigger no match zoom levels at 170% and 240%.

We could add those Firefox zoom levels to the list… or we could do something better! That is, use max and min resolution depending on whether we’re in the subunitary case or not, and also reverse the order of the subunitary zooms. The second part is because if we were to have the same order, with .9 being after .8, then the (max-resolution: .9x) case would override the (max-resolution: .8x) one.

$f: .9 .8 .75 .67 .5 .33 .25 
    1.1 1.2 1.33 1.4 1.5 1.7 2 2.4 3 4 5;

$n: length($f);

@for $i from 0 to $n {
  $c: nth($f, $i + 1);

  @media (#{if($c < 1, 'max', 'min')}-resolution: $c*1x) {
    div { --f: #{$c} }
  }
}

A more subtle change from before is that, when the zoom levels are above 1, we are using the slightly smaller of two zoom values that are close enough in Chrome and Firefox, but not quite the same. For example, between 1.25 in Chrome and 1.2 in Firefox we use 1.2, between 2.5 in Chrome and 2.4 in Firefox, we use 2.4. This is because the (min-resolution: 1.2x) case also catches the entire (min-resolution: 1.25x) case, but not the other way around. And the same thing goes for the other close, but not quite the same zoom level pairs from the two browsers.

Much better! But what if we really hate having so many media queries?

The less code and more flexible JS solution

In this case, we’d set f from the JS as follows:

function zoom() {
  document.body.style.setProperty('--f', window.devicePixelRatio);
  matchMedia(`(resolution: ${window.devicePixelRatio}x)`)
    .addEventListener('change', zoom, { once: true });
}

zoom();

This works for any place where we may want to have radial-gradient() created discs – not just for background values, but also for mask or border-image values.

Conclusion

Is this overkill? Something only a psycho would do? It depends.

In some cases, having smooth edges may be worth obsessing about. For example, if we use a mask as a fallback for shape() in the case of a component (like a header) with both convex and concave roundings.

Screenshot. Shows a header with both convex and concave roundings.
screenshot of a header component with both convex and concave roundings (live demo)

While newer Chrome and Safari versions have supported shape() for a few months now, Firefox support isn’t there yet. We could set the layout.css.basic-shape-shape.enabled flag to true in about:config to play with it there too, but remember, most people won’t have it enabled, and there is a reason why it’s still behind the flag in Firefox: not all commands work. We can use the lines and arcs we need for this particular shape, but Bézier curves don’t work yet. Furthermore, some people may be stuck on older hardware/ operating systems and may be unable to update Chrome or Safari to the latest version. So having a fallback for shape() is very much necessary.

Without the zoom/device pixel ratio factor, we get ugly blurry edges for the concave rounding (the convex one is created via border-radius, so it doesn’t have this problem) at a zoom level of 500% when shape() isn’t supported and the mask fallback is used (for example, in Firefox without the flag enabled).

Screenshot. Shows the same header with both convex and concave roundings. We can see that the page has been zoomed in up to a level of 500% and that the edges of the concave roundings (obtained using radial-gradient() mask layers) are blurry.
the problem when using the fallback without the zoom/ device pixel ratio factor correction

There are, however, other cases where we could embrace (and maybe even enhance) the blurry edges instead of doing anything about them. For example, when the discs are a part of a faded background.

]]>
https://frontendmasters.com/blog/obsessing-over-smooth-radial-gradient-disc-edges/feed/ 2 6737
Get the number of auto-fit/auto-fill columns in CSS https://frontendmasters.com/blog/count-auto-fill-columns/ https://frontendmasters.com/blog/count-auto-fill-columns/#comments Wed, 06 Aug 2025 15:08:24 +0000 https://frontendmasters.com/blog/?p=6567 Ever wanted to get the number of auto-fit/auto-fill columns in a grid? For example, because you want to highlight just the items in the first or last row or column? Do something special just for even or for odd rows or columns (e.g. zebra striping)? Or for any one specific row or column? Create responsive non-rectangular grids? And all of this with zero breakpoints?

This is all doable with pure CSS by using container query units, CSS variables, and CSS mathematical functions! Of course, it also involves navigating browser bugs and support gaps. But at the end of the day, it is possible to do it cross-browser!

Let’s see how!

The Basic Idea

Setup

We start with a .grid with a lot of items, let’s say 100. I normally prefer to generate them in a loop using a preprocessor to avoid clutter in the HTML and to make it easy to change their number, but it’s also possible to do so using Emmet. For the demos illustrating the concept here, we’re using Pug, and also numbering our items via their text content:

.grid
- for(let i = 0; i < 100; i++)
.item #{i + 1}

Our .grid has auto-fit columns:

.grid {
  --u: 7em;

  display: grid;
  grid-template-columns: repeat(auto-fit, var(--u));
  container-type: inline-size
}

This means our .grid has as many columns of unit width u as can fit within its own content-box width. This width is flexible and is given by the page layout, we don’t know it. However, its children (the .item elements) can know it as 100cqw in container query units. To have these container units available for the .grid element’s children (and pseudos), we’ve made the .grid an inline container.

This should work just fine. And it does, in both Chrome and Firefox. However, if we try it out in Safari, we see our .grid is collapsed into a point. Unfortunately, in Safari, auto-fit grids break if they are also containers. (Note: this Safari bug is actually fixed, it’s just waiting to make its way to a stable release.)

We have two options in this case.

The first would be to replace auto-fit with auto-fill. When we have as many items as we do in this case, we can use either of them, the difference between them is only noticeable when we don’t even have enough items to fill one row.

.grid {
  --u: 7em;

  display: grid;
  grid-template-columns: repeat(auto-fill, var(--u));
  container-type: inline-size
}

The second would be to put the .grid inside a wrapper .wrap element and move the container property on the wrapper.

.wrap { container-type: inline-size }

.grid {
  --u: 7em;

  display: grid;
  grid-template-columns: repeat(auto-fit, var(--u))
}

We’re going for the first option here.

Now we’re getting to the interesting part!

Getting the number of columns

In theory, we could get the number n of columns on the .item children of the .grid via division, whose result we round down (if the container width of 100cqw is 2.23 times the unit width u of a column, then we round down this ratio to get the number of columns we can fit, which is 2 in this case):

--n: round(down, 100cqw/var(--u))

In practice, while this should work, it only works in Safari (since Sept 2024) and in Chrome (since June 2025), where we can test it out by displaying it using the counter hack:

.grid::before {
  --n: round(down, 100cqw/var(--u));

  counter-reset: n var(--n);
  content: counter(n)
}

We’ve wrapped this inside a @supports block so we have a message that lets us know about this failing in non-supporting browsers (basically Firefox), where we see the following:

base_idea_fallback
the result in non-supporting browsers: no number of columns can be computed

In Safari and Chrome, things look like in the recording below:

We can see we have a problem when we have one column and it overflows the parent: the ratio between the parent .grid width of 100cqw and the column unit width u drops below 1, so we can fit one item 0 times inside the content-box width of the .grid. And this is reflected in the n value, even though, in practice, we cannot have a grid with less than one column. However, the fix for this is simple: use a max() function to make sure n is always at least 1.

--n: max(1, round(down, 100cqw/var(--u)))

Whenever the division result drops below 1, the result of the max() function isn’t the round() value anymore, but 1 instead.

You can see it in action in demo below, but keep in mind it can only compute the number of columns in supporting browsers (Safari/Chrome):

Great, but what Firefox? The Firefox bug looks like it’s dormant, so we cannot get the ratio between two length values there.

Extending support

However, we have a clever hack to solve the problem!

The idea behind is the following: the tangent of an acute angle in a right triangle is the ratio between the length of the cathetus opposing the angle and the length of the cathetus adjacent to it. So basically, the tangent is a ratio between two length values and such a ratio is precisely what we need.

A diagram illustrating basic trigonometry, featuring the tangent function, labeled 'tan(a) = opposing / adjacent', with definitions for 'opposing cathetus' and 'adjacent cathetus', and an angle 'a' represented in a right triangle.
basic trigonometry recap

Now you may be wondering what right triangle and what angle do we even have here. Well, we can imagine building a triangle where a cathetus has the same length as the .grid parent’s content-box width (100cqw on the .item elements, which we’ll call w) and the other has the same length as the column unit width (u).

The tangent of the angle opposing the cathetus of length w is the ratio between w and u. Okay, but what is this angle?

A mathematical diagram illustrating the relationship between the tangent function, the angle 'a', and the lengths 'w' and 'u' in a right triangle.
using trigonometric functions to get around browser support gaps

We can get this angle using the atan2() function, which takes two arguments, the length of the opposing cathetus w and the length of the adjacent cathetus u:

--a: atan2(var(--w), var(--u))

Having the angle a and knowing that the ratio f between w and u is the tangent of this angle, we can write:

--f: tan(var(--a))

Or, replacing the angle in the formula:

--f: tan(atan2(var(--w), var(--u)))

In general, know that a length ratio like w/u can always be computed as tan(atan2(w, u)).

Rounding down this ratio f gives us the number of columns of unit width u that fit within the .grid parent’s content-box width w.

--n: round(down, var(--f))

So we can write it all as follows, introducing also the correction that the number of columns needs to be at least 1:

--f: tan(atan2(var(--w), var(--u)));
--n: max(1, round(down, var(--f)))

That’s it, that’s the formula for --n in the case when we don’t have support for getting the ratio of two length values! There is one catch, though: both --w and --u have to be registered as lengths in order for atan2() to work properly!

Putting it all together, the relevant code for our demo looks as follows:

.grid {
  --u: 7em;

  display: grid;
  grid-template-columns: repeat(auto-fill, var(--u));
  container-type: inline-size
}

.grid::before, .item {
  --w: 100cqw;
  --f: var(--w)/var(--u);
  --n: max(1, round(down, var(--f)));
}

@supports not (scale: calc(100cqh/3lh)) {
  @property --w {
    syntax: '<length-percentage>';
    initial-value: 0px;
    inherits: true
  }

  @property --u {
    syntax: '<length-percentage>';
    initial-value: 0px;
    inherits: true
  }
	
  .grid::before, .item { --f: tan(atan2(var(--w), var(--u))) }
}

Note that the .grid pseudo is only needed to display the --n value (using the counter hack) for us to see in the demo without having to register it and then look for it in DevTools (which is the tactic I most commonly use to check the computed value of a CSS variable).

Almost there, but not exactly.

Fixing tiny issues

If you’ve played with resizing the demo above, you may have noticed something is off in Firefox at times. At certain points when the .grid element’s content-box width w is a multiple of the unit column width u, for example, when w computes to 1008px and the unit column with u of 112px fits inside it exactly 9 times, Firefox somehow computes the number of columns as being smaller (8 instead of 9, in this example).

My first guess was this is probably due to some rounding errors in getting the angle via atan2() and then going back from an angle to a ratio using tan(). Indeed, if we register --f so we can see its value in DevTools, it’s displayed as 8.99999 in this case, even though 1008px/112px is exactly 9.

Screenshot of browser developer tools showing a grid layout with items numbered from 1 to 18. The grid has a header indicating the number of auto-fill columns, which is 8. The inspector highlights CSS variables related to grid sizing.
rounding error caught by Firefox DevTools

So this means rounding down f results in the number of columns n being computed as 8, even though it’s actually 9. Hmm, in this case, it might be better to round f to a tiny precision of .00001 before rounding it down to get the number of columns n:

--f: round(tan(atan2(var(--w), var(--u))), .00001)

This seems to get the job done.

Still, I’m a bit worried this still might fail in certain scenarios, even though I’ve kept resizing obsessively in Firefox and haven’t encountered any problems after rounding f.

So let’s make sure we’re on the safe side and place the .grid in a wrapper .wrap, make this wrapper the container, compute the number of columns n on the .grid and use it to set the grid-template-columns. This way, the essential CSS becomes:

.wrap {
  container-size: inline-type;
}

.grid {
  --w: 100cqw;
  --u: 7em;
  --f: var(--w) / var(--u);
  --n: max(1, round(down, var(--f)));

  display: grid;
  grid-template-columns: repeat(var(--n), var(--u));
  justify-content: center;
}

@supports not (scale: calc(100cqh / 3lh)) {
  @property --w {
    syntax: "<length-percentage>";
    initial-value: 0px;
    inherits: true;
  }

  @property --u {
    syntax: "<length-percentage>";
    initial-value: 0px;
    inherits: true;
  }

  .grid {
    --f: round(tan(atan2(var(--w), var(--u))), 0.00001);
  }
}

Note that we may also use 1fr instead of var(--u) for the grid-template-columns property if we want the .item elements to stretch.

Mind the gap

Nice, but oftentimes we also want to have a gap in between our rows and columns, so let’s see how the number of columns can be computed in that case.

Whenever we have n columns, we have n - 1 gaps in between them.

This means that n times the unit column width plus (n - 1) times the gap space adds up to the container’s content-box width:

n·u + (n - 1)·s = w

If we add s on both sides in the equation above, we get:

n·u + (n - 1)·s + s = w + s ⇒ 
n·u + n·s - s + s = w + s ⇒
n·u + n·s = w + s ⇒
n·(u + s) = w + s ⇒
n = (w + s)/(u + s)

Putting this into CSS, our ratio looks as follows:

(var(--w) + var(--s))/(var(--u) + var(--s))

Note that in our case, it’s the fraction f that we compute this way before we round it to get the number of items n and ensure n is always at least 1.

Also note that the CSS variables we need to register for the no calc() length division fallback are the numerator and denominator of this fraction. So our essential CSS becomes:

.wrap {
  container-size: inline-type;
}

.grid {
  --w: 100cqw;
  --u: 7em;
  --s: 3vmin;
  --p: calc(var(--w) + var(--s)); /* numerator */
  --q: calc(var(--u) + var(--s)); /* denominator */
  --f: var(--p) / var(--q);
  --n: max(1, round(down, var(--f)));

  display: grid;
  grid-gap: var(--s);
  grid-template-columns: repeat(var(--n), 1fr);
}

@supports not (scale: calc(100cqh / 3lh)) {
  @property --p {
    /* numerator */
    syntax: "<length-percentage>";
    initial-value: 0px;
    inherits: true;
  }

  @property --q {
    /* denominator */
    syntax: "<length-percentage>";
    initial-value: 0px;
    inherits: true;
  }

  .grid {
    --f: round(tan(atan2(var(--p), var(--q))), 0.00001);
  }
}

Let’s Go Wild!

And let’s see where we can use this!

Highlighting items on a certain column

In order to do something like this, we use the item indices. Once sibling-index() is supported cross-browser, we’ll be able to do this:

.item { --i: calc(sibling-index() - 1) }

Note that we need to subtract 1 because sibling-index() is 1-based and we need our index i to be 0-based for modulo and division purposes.

Until then, we add these indices in style attributes when generating the HTML:

.grid
- for(let i = 0; i < 100; i++)
.item(style=`--i: ${i}`) #{i + 1}

Let’s say we want to highlight the items on the first column. We get the number of columns n just like before. An item is on the first column if i%n (which gives us the 0-based index of the column an item of index i is on) is 0. Now given I used the word if there, you might be thinking about the new CSS if() function. However, we have a way better supported method here.

If the column index i%n is 0, then min(1, i%n) is 0. If the column index i%n isn’t 0, then min(1, i%n) is 1. So we can do the following:

.item {
  --nay: min(1, mod(var(--i), var(--n))); /* 1 if NOT on the first column */
  --yay: calc(1 - var(--nay)); /* 1 if on the first column! */
}

So then we can use --yay to highlight the items on the first column by styling them differently, for example by giving them a different background:

.item {
  --nay: min(1, mod(var(--i), var(--n))); /* 1 if NOT on the first column */
  --yay: calc(1 - var(--nay)); /* 1 if on the first column! */

  background: color-mix(in srgb, #fcbf49 calc(var(--yay)*100%), #dedede)
}

You can see it in action in the live demo below:

Now let’s say we want to highlight the items on the last column. In this case, the column index i%n is n - 1, which means that their difference is 0:

(n - 1) - (i%n) = 0

Using this, we can do something very similar to what we did before, as the minimum between 1 and this difference is 0 for items on the last column and 1 for those that aren’t on the last column:

.item {
  /* 1 if NOT on the last column */
  --nay: min(1, (var(--n) - 1) - mod(var(--i), var(--n))));
  /* 1 if on the last column! */
  --yay: calc(1 - var(--nay));
}

For example, if n is 7, then the column index i%n can be 0, 1, … 6 and n - 1 is 6. If our item of index i is on the last column, then its column index i%n = i%7 = 6, so the difference between n - 1 = 7 - 1 = 6 and i%n = i%7 = 6 is 0. If our item of index i isn’t on the last column, then its column index i%n = i%7 < 6, so the difference between n - 1 = 6 and i%n < 6 is 1 or bigger. Taking the minimum between 1 and this difference ensures we always get either 0 or 1.

In general, if we want to highlight a column of index k (0-based, but we can just subtract 1 in the formula below if it’s given 1-based), we need to compute the difference between it and i%n (the column index of an item of index i), then use the absolute value of this difference inside the min():

.item {
  --dif: var(--k) - mod(var(--i), var(--n));
  --abs: abs(var(--dif));
  --nay: min(1, var(--abs)); /* 1 if NOT on column k */
  --yay: calc(1 - var(--nay)); /* 1 if on column k! */
}

The difference and its absolute value are 0 when the item of index i is on column k and different (bigger in the case of absolute value) when it isn’t.

We need the absolute value here because, while the difference between n - 1 and i%7 is always 0 or bigger, that is not the case for the difference between any random k and i%n. For example, if n is 7 and k is 2, the k - i%n difference is negative when k is smaller than i%n, for example when i%n is 5. And we need the difference that goes into the min() to be 0 or bigger in order for the min() to always give us either 0 or 1.

All modern stable browsers support abs(), but for the best possible browser support, we can still test for support and use the fallback:

@supports not (scale: abs(-2)) {
  .item { --abs: max(var(--dif), -1*(var(--dif))) }
}

Also, note that if the selected column index k is equal to n or bigger, no items get selected.

In the interactive demo below, clicking an item selects all items on the same column:

It does this by setting --k (in the style attribute of the .grid) to the index of that column.

A code snippet from a web developer's browser console showcasing CSS rules for items in a grid layout, including custom properties for styling.
Chrome DevTools screenshot showing --k being set on the .grid parent and used in computations on .item children

We can also highlight items on either odd or even columns:

.item {
  /* 1 if on an even column, 0 otherwise */
  --even: min(1, mod(mod(var(--i), var(--n)), 2));
  /* 1 if on an odd colum, 0 otherwise */
  --odd: calc(1 - var(--even));
}

This is a particular case of highlighting every k-th column starting from column j (again, j is a 0-based index and smaller than k):

.item {
  --dif: var(--j) - mod(mod(var(--i), var(--n)), var(--k));
  --abs: abs(var(--dif));
  --nay: min(1, var(--abs));
  /* 1 if on one of every kth col starting from col of index j */
  --yay: calc(1 - var(--nay));
}

Highlighting items on a certain row

If we want to highlight the items on the first row, this means their index i must be smaller than the number of columns n. This means the difference n - i must be bigger than 0 for items on the first row. If we clamp it to the [0, 1] interval, we get a value that’s 0 on every row but the first and 1 on the first row.

.item {
  --yay: clamp(0, var(--n) - var(--i), 1)  /* 1 if on the first row! */
}

There is more than one way to skin a cat however, so another approach would be to get the row index, which is the result of i/n rounded down. If this is 0, the item of index i is on the first row. If it’s bigger than 0, it isn’t. This makes the minimum between 1 and i/n rounded down be 0 when the item of index i is on the first row and 1 when it isn’t.

.item {
  --nay: min(1, round(down, var(--i)/var(--n))); /* 1 if NOT on the first row */
  --yay: calc(1 - var(--nay)); /* 1 if on the first row! */
}

This second approach can be modified to allow for highlighting the items on any row of index k as the difference between k and i/n rounded down (the row index) is 0 if the item of index i is on the row of index k and non-zero otherwise:

.item {
  --dif: var(--k) - round(down, var(--i)/var(--n));
  --abs: abs(var(--dif));
  --nay: min(1, var(--abs)); /* 1 if NOT on row of index k */
  --yay: calc(1 - var(--nay)); /* 1 if on row of index k! */
}

Highlighting the items on any row includes the last one. For this, we need to know the total number t of items on our grid. This means (t - 1) is the index of the last grid item, and we can get the index of the row it’s on (that is, the index of the final row) by rounding down (t - 1)/n. Then we substitute k in the previous formula with the index of the final row we’ve just obtained this way.

.item {
  /* 1 if NOT on last row */
  --nay: min(1, round(down, (var(--t) - 1)/var(--n)) - round(down, var(--i)/var(--n)));
  /* 1 if on last row! */
  --yay: calc(1 - var(--nay));
}

There are two things to note here.

One, we don’t need the absolute value here anymore, as the last row index is always going to be bigger or equal to any other row index.

Two, we’re currently passing the total number of items t to the CSS as a custom property when generating the HTML:

- let t = 24; //- total number of items on the grid

.wrap
.grid(style=`--t: ${t}`)
- for(let i = 0; i < t; i++)
.item(style=`--i: ${i}`) #{i + 1}

But once sibling-count() is supported cross-browser, we won’t need to do this anymore and we’ll be able to write:

.item { --t: sibling-count() }

Just like before, we can highlight items on odd or even rows.

.item {
  /* 1 if on an even row */
  --even: min(1, mod(round(down, var(--i)/var(--n)), 2));
  /* 1 if on an odd row */
  --odd: calc(1 - var(--even));
}

And the odd/ even scenario is a particular case of highlighting items on every k-th row, starting from row of index j.

.item {
  --dif: var(--j) - mod(round(down, var(--i)/var(--n)), var(--k));
  --abs: abs(var(--dif));
  --nay: min(1, var(--abs));
  /* 1 if on one of every kth row starting from row of index j */
  --yay: calc(1 - var(--nay));
}

Taking it Further

Another thing this technique can be used for is creating responsive grids of non-rectangular shapes with no breakpoints. An example would be the hexagon grid below. We aren’t going into the details of it here, but know it was done using this technique plus a few more computations to get the right hexagon alignment.

]]>
https://frontendmasters.com/blog/count-auto-fill-columns/feed/ 1 6567
Step Gradients with a Given Number of Steps https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/ https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/#respond Mon, 30 Jun 2025 17:30:02 +0000 https://frontendmasters.com/blog/?p=6338 Let’s say we want some stepped gradients like the ones below, with a certain number of steps.

Desired_linear_result screenshot. Shows a bunch of linear gradients in 10 steps from left to right.
the desired result

Before reading further, try thinking about this. You are only given the start and end steps and the rest should be obtained via linear interpolation. How would you create them? Maybe put together a quick demo.

Note that this is a different problem from stepped gradients with a certain step size. The given step size problem is way more complex and it’s impossible to solve with CSS alone as long as we don’t have native looping. SVG filters provide a solution, though they limit us to using just pixel values for the step size; having em or calc() values there isn’t possible without JS.

The Classic CSS Approach

This means computing the intermediate step values and specifying a stop and positions for each step. Something like this:

.step {
  background: linear-gradient(
    90deg,
    #00272b 10%,
    #193f2f 0 20%,
    #325833 0 30%,
    #4b6f36 0 40%,
    #64873b 0 50%,
    #7c9f3f 0 60%,
    #95b743 0 70%,
    #aecf47 0 80%,
    #c7e74b 0 90%,
    #e0ff4f 0
  );
}

Tedious!

And this is the simplified version, the one that avoids repeating stops and stop positions, something that has been well-supported cross-browser for over half a decade, by the way. Because I often see the ancient, inefficient version that duplicates everything:

.step {
  background: linear-gradient(
    90deg,
    #00272b 0%,  #00272b 10%,
    #193f2f 10%, #193f2f 20%,
    #325833 20%, #325833 30%,
    #4b6f36 30%, #4b6f36 40%,
    #64873b 40%, #64873b 50%,
    #7c9f3f 50%, #7c9f3f 60%,
    #95b743 60%, #95b743 70%,
    #aecf47 70%, #aecf47 80%,
    #c7e74b 80%, #c7e74b 90%,
    #e0ff4f 90%, #e0ff4f 100%
  );
}

We could generate the stop list using Sass with looping and the mix() function.

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
@function stop-list($c0, $c1, $n) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l, mix($c1, $c0, $i * 100%/ ($n - 1)) 0 ($i + 1) * 100% / $n;
  }

  @return $l;
}

.step {
  background: linear-gradient(90deg, stop-list(#00272b, #e0ff4f, 10));
}

This produces the following compiled result:

.step {
  background: linear-gradient(
    90deg,
    #00272b 0 10%,
    #193f2f 0 20%,
    #325833 0 30%,
    #4b6f36 0 40%,
    #64873b 0 50%,
    #7c9f3f 0 60%,
    #95b743 0 70%,
    #aecf47 0 80%,
    #c7e74b 0 90%,
    #e0ff4f 0 100%
  );
}

Not bad — but we should probably tweak that generating function a bit so we get rid of the unnecessary 0 and 100% stop positions at the very start and at the very end and to add rounding in case 100 is not a multiple of $n.

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
@function stop-list($c0, $c1, $n) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      mix($c1, $c0, $i * 100%/ ($n - 1))
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n, round(($i + 1) * 100% / $n), unquote(""));
  }

  @return $l;
}

Much better — but the Sass function doesn’t look pretty and it gets even more complex if we need to round those percentages to a certain precision $p, a certain number of decimals, not just to integer percentage values:

@use "sass:math";

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      mix($c1, $c0, $i * 100%/ ($n - 1))
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if(
        $i < $n - 1,
        round(($i + 1) * 100% / $n * math.pow(10, $p)) * math.pow(10, -$p),
        unquote("")
      );
  }

  @return $l;
}

We still have that long list of values in the compiled CSS and, if we have 7 elements like this with stepped gradients, each is going to get its own long list.

Another problem with this is we cannot tweak the stepped gradient from DevTools. Not in a non-tedious way that doesn’t involve changing almost every step manually. If we want to change one of the end steps from DevTools, we have to also change all the Sass-computed intermediate steps.

Screenshot showing the Sass code that generates the steps and the visual result. On the right, a DevTools panel is open and we're modifying the first step in the compiled code the browser can see, but this doesn't change the Sass-generated intermediate ones.
what happens when we change one end step from DevTools

Using CSS variables can get around both these problems, but we cannot use CSS variable values inside the Sass mix() function. In order to use CSS variables, we have to use CSS color-mix() function:

@use "sass:math";

// $n: number to round to a certain precision
// $p: rounding precision, how many decimals to keep
@function round-to($n, $p: 2) {
  @return round($n * math.pow(10, $p)) * math.pow(10, -$p);
}

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      color-mix(in srgb, $c1 round-to($i * 100%/ ($n - 1), $p), $c0)
        // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
        // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n - 1, round-to(($i + 1) * 100% / $n, $p), unquote(""));
  }

  @return $l;
}

Which produces the following ugly compiled CSS:

.step {
  background: linear-gradient(
    90deg,
    color-mix(in srgb, var(--c1) 0%, var(--c0)) 10%,
    color-mix(in srgb, var(--c1) 11.11%, var(--c0)) 0 20%,
    color-mix(in srgb, var(--c1) 22.22%, var(--c0)) 0 30%,
    color-mix(in srgb, var(--c1) 33.33%, var(--c0)) 0 40%,
    color-mix(in srgb, var(--c1) 44.44%, var(--c0)) 0 50%,
    color-mix(in srgb, var(--c1) 55.56%, var(--c0)) 0 60%,
    color-mix(in srgb, var(--c1) 66.67%, var(--c0)) 0 70%,
    color-mix(in srgb, var(--c1) 77.78%, var(--c0)) 0 80%,
    color-mix(in srgb, var(--c1) 88.89%, var(--c0)) 0 90%,
    color-mix(in srgb, var(--c1) 100%, var(--c0)) 0
  );
}

We can tweak the Sass to get rid of the first and last color-mix() and use the given ends --c0 and --c1 instead (live demo):

@use "sass:math";

// $n: number to round to a certain precision
// $p: rounding precision, how many decimals to keep
@function round-to($n, $p: 2) {
  @return round($n * math.pow(10, $p)) * math.pow(10, -$p);
}

// $c0: gradient start
// $c1: gradient end
// $n: number of steps
// $p: rounding precision, how many decimals to keep
@function stop-list($c0, $c1, $n, $p: 2) {
  $l: (); // list of stops, initially empty

  @for $i from 0 to $n {
    $l: $l,
      if(
          $i > 0,
          if(
            $i < $n - 1,
            color-mix(in srgb, $c1 round-to($i * 100%/ ($n - 1), $p), $c0),
            $c1
          ),
          $c0
        )
      // 1st stop position for each stop
      // not set (empty '') for very first stop
      if($i > 0, 0, unquote(""))
      // 2nd stop position for each stop
      // not set (empty '') for very last stop
      if($i < $n - 1, round-to(($i + 1) * 100% / $n, $p), unquote(""));
  }

  @return $l;
}

But the generated CSS still looks ugly and difficult to read:

.step {
  background: linear-gradient(
    90deg,
    var(--c0) 10%,
    color-mix(in srgb, var(--c1) 11.11%, var(--c0)) 0 20%,
    color-mix(in srgb, var(--c1) 22.22%, var(--c0)) 0 30%,
    color-mix(in srgb, var(--c1) 33.33%, var(--c0)) 0 40%,
    color-mix(in srgb, var(--c1) 44.44%, var(--c0)) 0 50%,
    color-mix(in srgb, var(--c1) 55.56%, var(--c0)) 0 60%,
    color-mix(in srgb, var(--c1) 66.67%, var(--c0)) 0 70%,
    color-mix(in srgb, var(--c1) 77.78%, var(--c0)) 0 80%,
    color-mix(in srgb, var(--c1) 88.89%, var(--c0)) 0 90%,
    var(--c1) 0
  );
}

So… isn’t there another way?

The No-Support Ideas

The spec defines a stripes() image function and my first thought was it should allow us to do this, though it was not clear to me in which direction the stripes would go, if we have a way of specifying that:

.step {
  background: stripes(
    #00272b,
    #193f2f,
    #325833,
    #4b6f36,
    #64873b,
    #7c9f3f,
    #95b743,
    #aecf47,
    #c7e74b,
    #e0ff4f
  );
}

But the more I read the first paragraph in the spec definition, the more it sounds like this wasn’t meant for backgrounds, but for stripes going along the direction of things like borders (including roundings) and text strokes. In this case, the line direction would be the “missing” direction.

There’s also a proposal to add animation-like gradient easing, including a steps() function, though, just like in the case of the stripes() function, there’s a lot about this I don’t understand and the proposal doesn’t seem to have moved much lately.

Since neither of these can be used today, what other solutions that we can currently use do we have?

Enter the SVG filter Enhancement

Wait, don’t run away screaming! I promise that, leaving aside small browser issues, the technique is actually simple and even has better support than the CSS gradient double stop position syntax!

Let’s say we have a plain black to red gradient on an element that also gets an SVG filter:

.step {
  background: linear-gradient(90deg, #000, #f00);
  filter: url(#step)
}

We’ve picked this gradient in particular because it’s a gradient from 0% to 100% one one of the RGBA channels (in this case, the red channel R) while all other channel values stay constant. It could also be written as:

.step {
  background: linear-gradient(90deg, rgb(0% 0% 0% / 1), rgb(100% 0% 0% / 1));
  filter: url(#step)
}

Writing it like this makes it even more obvious that the green and blue channels (the second and third values in the rgb()) are zeroed everywhere all the time (before applying the filter and after too, as the SVG filter, which we’ll see in a second, doesn’t affect any channel other than the red one), while the alpha (the final value, the one after the slash in the rgb()) is maxed out everywhere all the time.

So basically, we go from a 0% red (which is equivalent to black) at the start on the left to a 100% red (which is the same as the red keyword value) at the end on the right.

a simple left to right black to red gradient band
our initial gradient

This SVG filter needs to live inside an svg element. Since this svg element only exists to contain the filter, we don’t have any graphics that are going to be visible on the screen within it, it is functionally the same as a style element. So we zero its dimensions (width and height attributes), hide it from screen readers (aria-hidden) and take it out of the document flow (from the CSS).

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
  </filter>
</svg>
svg[height='0'][aria-hidden='true'] { position: fixed }

The filter element also gets another attribute other than the id. We aren’t going into it, just know we need to set it to sRGB for cross-browser compatibility if we mess with the RGB channels, as the spec default and the one used by all browsers nowadays is linearRGB, but sRGB is likely what we want in most cases, plus it used to be the only value that worked in Safari, though that has recently changed.

In our particular case, if we don’t set the color-interpolation-filters attribute to sRGB, we won’t get equally sized steps in any browser other than older Safari versions which use sRGB anyway.

Inside this filter, we have a feComponentTransfer primitive. This allows us to manipulate the RGBA channels individually (via the suggestively named feFuncR, feFuncG, feFuncB and feFuncA) in various ways. In this case, we have a gradient from black (0% red) to red (100% red) so we manipulate the red channel using feFuncR.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
    <feComponentTransfer>
      <feFuncR type='discrete' tableValues='0 1'/>
    </feComponentTransfer>
  </filter>
</svg>

We’ve set the type of feFuncR to discrete, meaning the output red channel only has discrete values and these values are those specified by the tableValues attribute, so in our case here, 0 and 1 (1 being the decimal representation of 100%).

What does this mean? Where the input value for the red channel is below 50% (.5), so on the left half of the initial gradient, the filter output value for the red channel is 0 (zeroed). And where the input value for the red channel is at least 50% (.5), so on the right half of the initial gradient, the filter output for the red channel is 1 (maxed out). Since the green and blue channels are zero everyehere, this makes the left half of our gradient a black step and the right half a red step.

Screenshot of the two step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in two steps: black and red). On top of these gradients, we also have the boundary lines for the two intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 2 steps, before and after applying the step filter

Basically, when we have two values for tableValues, the [0, 1] interval of possible red input values gets split into two: [0, .5) and [.5, 1]. The first interval [0, .5) gets mapped to the first of the two tableValues and the second interval [.5 1] gets mapped to the second of the two tableValues.

Now let’s say we add a .5 value in between:

<feFuncR type='discrete' tableValues='0 .5 1'/>

This gives us three steps from left to right: a 0% red (black), a 50% red (maroon) and a 100% red (red).

Now that we have three values for tableValues, the [0, 1] interval of possible red input values gets split into three: [0, .(3)), [.(3),.(6)) and [.(6), 1]. The first one, in our case being the left third of the gradient, gets mapped to 0, so we have 0% red (black) there. The second one, in our case being the middle third of the gradient, gets mapped to .5, so we have 50% red (marron) there. The third one, in our case being the right third of the gradient, gets mapped to 1, so we have 100% red (red) there.

Screenshot of the three step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in three steps: black, maroon and red). On top of these gradients, we also have the boundary lines for the three intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 3 steps, before and after applying the step filter

We can also have four equally spaced values for tableValues:

<feFuncR type='discrete' tableValues='0 .333 .667 1'/>

This gives us 4 steps from left to right:

Screenshot of the four step gradient example. Shows the filter input (the initial left to right, black to red gradient) and output (the filtered gradient, the one in four steps). On top of these gradients, we also have the boundary lines for the four intervals the [0%, 100%] interval of the red channel progression (which coincides with the input gradient) is split into. At the bottom, the feFuncA primitive creating this result is also shown.
a black to red stepped gradient with 4 steps, before and after applying the step filter

In general, n equally spaced values for tableValues produce n equal steps for our black to red gradient:

adjusting the number of steps adjusts tableValues and the visual result (live demo – note that if you’re on a wide gamut display, you’re likely to see this broken in Chrome)

If we use Pug, we can easily generate these values within a loop:

- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(2));

svg(width='0' height='0' aria-hidden='true')
filter#step(color-interpolation-filters='sRGB')
feComponentTransfer
feFuncR(type='discrete' tableValues=`${a.join(' ')}`)

Great, but this is a simple black to red gradient. How can we create a stepped orange to purple gradient, for example?

Extending the Technique: Different Palettes

This technique works for gradients where we vary any of the four RGBA channels from 0% to 100% along the gradient line while keeping all other three channels constant along the entire gradient line, though not necessarily zeroed or maxed out like in the black to red gradient example.

For example, we could make the green channel G go from 0% to 100% along the gradient line, while the red channel is fixed at 26% for the entire gradient, the blue channel is fixed at 91% for the entire gradient and the alpha channel is fixed at 83% for the entire gradient. This means we go from a slightly faded blue first step (rgb(26% 0% 91%/ 83%)) to a somewhat desaturated aqua (rgb(26% 100% 91%/ 83%)) for the final step.

Screenshot of a five step gradient example where we vary the green channel while keeping all others constant along the entire gradient line. Shows the filter input (the initial left to right, faded blue to desaturated aqua gradient) and output (the filtered gradientin five steps). On top of these gradients, we also have the boundary lines for the five intervals the [0%, 100%] interval of the green channel progression (which coincides with the input gradient progression) is split into.
our example gradient, initial and with a 5 step filter applied

Below you can see how you can play with an interactive demo that allows to create a custom 5 step gradient that has steps along one of the four channels while the others have a fixed value, custom set by us, but fixed for the entire gradient.

picking the gradient channel and adjusting values for the fixed ones (live demo – note that this demo may also suffer from the Chrome wide gamut bug)

Out of all these cases, the most interesting one is the one varying the alpha.

First off, using the alpha channel allows us to avoid both the wide gamut bug and another Chrome bug we hit when using one of the RGB channels, but not the alpha channel.

Chrome screenshot of four 5-step gradients obtained by varying each of the four RGBA channels while keeping all other constant. For those varying the RGB channels, the step edges are jagged, which doesn't happen when varying the alpha channel.
the step edges are jagged in Chrome for the steps created on the RGB channels, but not on the alpha channel (live test)

Secondly, what this allows us to do is to fade any RGB value in steps along the entire alpha interval. And if we place this stepped fade on top of another solid RGB background, we get our desired stepped gradient where we only need to know the start (the layer underneath, seen exactly as set for the first step where the alpha of the stepped gradient above is 0) and end step (used for the stepped gradient on top).

This is exactly how the gradients in the image at the start of the article were created. We have a .step element with a solid background set to the start step --c0 and a pseudo fully covering it with a gradient where we vary the alpha of the end step --c1.

.step {
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    background: linear-gradient(90deg, #0000, var(--c1));
    filter: url(#step);
    content: "";
  }
}

This pseudo has the alpha step filter applied.

//- change this to change number of steps
- let n = 10;
- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(3));

svg(width='0' height='0' aria-hidden='true')
filter#step
feComponentTransfer
feFuncA(type='discrete' tableValues=a.join(' '))

Note that in this case when we’re creating the steps on the alpha channel and we’re not touching the RGB channels, we don’t even need the color-interpolation-filters attribute anymore.

You can check out the live demo for various --c0 and --c1 combinations below:

And yes, in case anyone is wondering, the pure CSS and the SVG filter results are identical – you can check it out in this demo.

Simplifying the Technique for the Future

It feels a bit inconvenient to use pseudo-elements for this instead of just having a background. The filter() function solves this problem. It takes an image (which can also be a CSS gradient) and a filter chain as inputs, then outputs the filtered image. This output can be used anywhere an image can be used in CSS — as a background-image, mask-image, border-image, even shape-outside!

This way, our CSS can become:

.step {
  background: 
    filter(linear-gradient(90deg, #0000, var(--c1)), url(#step)) 
    var(--c0)
}

Much simpler!

The catch? While Safari has supported this for a decade (I first learned about this function and the Safari implementation in the summer of 2015!), no other browser has followed since. Here are the Chrome and Firefox bugs for anyone who wants to show interest and add to the use cases.

Here is the filter() version of the stepped gradients demo, but keep in mind it only works in Safari.

Extending the Technique: Back and Forth Steps

Now let’s say we wanted to modify our gradient to go back to black from red in the middle (we’re using the red and black gradient example here because of the contrast):

.step {
  background: linear-gradient(90deg, #000, #f00, #000);
  filter: url(#step)
}

The filter is generated exactly the same as before:

- let n = 5;
- let a = new Array(n).fill(0).map((_, i) => +(i/(n - 1)).toFixed(3));

svg(width='0' height='0' aria-hidden='true')
filter#step(color-interpolation-filters='sRGB')
feComponentTransfer
feFuncR(type='discrete' tableValues=`${a.join(' ')}`)

This Pug is producing the following HTML:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='step' color-interpolation-filters='sRGB'>
    <feComponentTransfer>
      <feFuncR id='func' type='discrete' tableValues='0 .25 .5 .75 1'/>
    </feComponentTransfer>
  </filter>
</svg>

In this case of a gradient going back, basically being reflected with respect to the middle, the middle red step is doubled when using the exact same red channel step gradient as before.

Screenshot of a 2·5 = 10 step gradient. Shows the filter input (the initial left to right, black to red and then back to black gradient) and output (the filtered gradient, in 5 + 5 steps). On top of these gradients, we also have the boundary lines for the intervals the [0%, 100%] interval of the red channel progression (which coincides with the first half of the input gradient) is split into. This simple linear gradient reflection results in a doubling of the middle red step.

This makes perfect sense. We’re reflecting the gradient and this repeats the 100% red step in the middle. We don’t have n steps across the gradient line anymore, we have 2·n of them, with the two in the middle having the same RGBA value (100% red).

What we need to do is make it look like we only have 2·n - 1 steps by making the two steps in the middle half the size of the other ones. This means moving the cutoff to 100% red ((n - 1)/n·100% red, which is 80% red in the n = 5 example case here) at half a 100%/(2·n - 1) interval from the middle, both before and after. So our CSS becomes:

.step {
  /* cutoff to 100% red */
  --r: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* distance from the middle of gradient line */
  --d: 0.5 * 100%/ (2 * var(--n) - 1);
  background: linear-gradient(
    90deg,
    #000,
    var(--r) calc(50% - var(--d)),
    #f00,
    var(--r) calc(50% + var(--d)),
    #000
  );
}

This does the trick!

Screenshot of a 2·5 = 10 step gradient. Shows the filter input (the initial left to right, black to red and then back to black gradient) and output (the filtered gradient, in 5 + 5 steps). On top of these gradients, we also have the boundary lines for the intervals the [0%, 100%] interval of the red channel progression (which coincides with the first half of the input gradient) is split into. The trick here is that the two middle intervals aren't equal to all the others, but half of them. This way, the middle red step is still doubled, but it's also half the size of all other steps, so it doesn't look like it's repeated twice.

The stops we’re specifying in the CSS in the particular case of n = 5 are the 0% red (implicitly at the 0% point of the gradient line), the 80% red at the 44.(4)% point of the gradient line (set explicitly), the 100% red (implicitly at the 50% point of the gradient line), the 80% red at the 55.(5)% point of the gradient line (also set explicitly) and the 0% red (implicitly at the 100% point of the gradient line).

If we wanted to fine tune things, we could also simplify the middle offset computations:

50% - d = 
50% - .5·100%/(2·n - 1) =
50% - 50%/(2·n -1) =
50%·(1 - 1/(2·n - 1)) =
50%·(1 - f)

So our CSS would become:

.step {
  /* cutoff to 100% red */
  --r: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* fraction of distance from middle of gradient line */
  --f: 1/ (2 * var(--n) - 1);
  background: linear-gradient(
    90deg,
    #000,
    var(--r) calc(50% * (1 - var(--f))),
    #f00,
    var(--r) calc(50% * (1 + var(--f))),
    #000
  );
}

Note that the SVG filter remains the exact same as before, we just pass the number of steps n to the CSS as a custom property:

- let n = 5;

// exact same SVG filter as before
.step(style=`--n: ${n}`)

If we want our gradient to repeat and we don’t want a doubled end/start step, we need to do something similar at the other end of the red scale (channel scale in general) and make it look as if the start/end step is 100%/(2·(n - 1)) of the gradient line (not 100%/(2·n - 1) like in the case of no gradient repetition reflection).

.step {
  /* cutoff to 0% red */
  --r0: rgb(calc(1 / var(--n) * 100%), 0%, 0%);
  /* cutoff to 100% red */
  --r1: rgb(calc((var(--n) - 1) / var(--n) * 100%), 0%, 0%);
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  background: linear-gradient(
      90deg,
      #000,
      var(--r0) calc(50% * var(--f)),
      var(--r1) calc(50% * (1 - var(--f))),
      #f00,
      var(--r1) calc(50% * (1 + var(--f))),
      var(--r0) calc(50% * (2 - var(--f))),
      #000
    )
    0/ 50%;
}

Note that we’ve used a background-size of 50%, which means 2 repetitions. For a generic number of repetitions q, our background-size is 100%/q.

For the alpha channel variation that allows us to get any gradient from any --c0 to any --c1, it’s very similar:

.step {
  /* cutoff to 0% alpha */
  --a0: rgb(from var(--c1) r g b/ calc(1 / var(--n)));
  /* cutoff to 100% alpha */
  --a1: rgb(from var(--c1) r g b/ calc((var(--n) - 1) / var(--n)));
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    background: linear-gradient(
        90deg,
        #0000,
        var(--a0) calc(50% * var(--f)),
        var(--a1) calc(50% * (1 - var(--f))),
        var(--c1),
        var(--a1) calc(50% * (1 + var(--f))),
        var(--a0) calc(50% * (2 - var(--f))),
        #0000
      )
      0 / calc(100% / var(--q));
    filter: url(#step);
    content: "";
  }
}

You can play with the demo below by changing the number of repetitions q to see how the result changes without needing to modify anything else.

What if we wanted to have full steps at the start of the first repetition and at the end of last repetition? Well, in that case, given a number q of repetitions, we can compute the width of the lateral borders to be equal to half a step size on each side. A step size is 1/(2·q·(n - 1) + 1) of the pseudo parent’s content-box width, so the border-width on the pseudo needs to be half of that.

.step {
  /* cutoff to 0% alpha */
  --a0: rgb(from var(--c1) r g b/ calc(1 / var(--n)));
  /* cutoff to 100% alpha */
  --a1: rgb(from var(--c1) r g b/ calc((var(--n) - 1) / var(--n)));
  /* fraction of distance from middle/ end of gradient line */
  --f: 1/ (2 * (var(--n) - 1));
  container-type: inline-size;
  position: relative;
  background: var(--c0);

  &::before {
    position: absolute;
    inset: 0;
    border: solid 0 #0000;
    border-width: 0 calc(50cqw / (2 * var(--q) * (var(--n) - 1) + 1));
    background: linear-gradient(
        90deg,
        #0000,
        var(--a0) calc(50% * var(--f)),
        var(--a1) calc(50% * (1 - var(--f))),
        var(--c1),
        var(--a1) calc(50% * (1 + var(--f))),
        var(--a0) calc(50% * (2 - var(--f))),
        #0000
      )
      0 / calc(100% / var(--q));
    filter: url(#step);
    content: "";
  }
}

Modified interactive demo:

What makes this possible and easy is the fact that, by default, background-size and background-position are relative to the padding-box (their position is relative to the top left corner of the padding-box, so that 0 position is relative to the padding-box left edge and their size is relative to the padding-box dimensions, so that 100% in calc(100%/var(--q)) is relative to the padding-box width), but extra background repetitions are painted in all directions under the border too.

The box model and the default way backgrounds are painted. Their size and position is relative to the padding-box, but repetitions are also painted underneath the borders.
by default, backgrounds cover the entire border-box, but start from the top left corner of the padding-box

Note that the whole reflect and repeat could be very much simplified on the CSS side if CSS gradients also allowed reflecting repetition like SVG ones do.

Extending the Technique: Different Gradients

We’ve only used a left to right linear-gradient() so far, but the direction of the gradient may vary and we may well use a radial-gradient() or a conic-gradient() instead. Nothing changes about the filter in this case. The gradients below all use the exact same filter.

Regardless of the gradient type, the filter() function is going to simplify things if Chrome and Firefox implement it too. The relevant code for the demo above would become:

.step {
  --c0: #00272b;
  --c1: #e0ff4f;
  --s: #0000, var(--c1);
  background: filter(var(--img, conic-gradient(var(--s))), url(#step)) var(--c0);
}

.linear {
  --img: linear-gradient(to right bottom, var(--s));
}
.radial {
  --img: radial-gradient(circle, var(--s));
}

You can check out the live demo, but remember it only works in Safari.

Refining Things

The results aren’t perfect. When using radial or conic gradients or even linear ones at weird angles, we get jagged edges in between the steps. I guess it doesn’t look that bad in between steps that may be reasonably similar, but if we wanted to do something about it, what could we do?

When creating the steps from the CSS, we can always use the 1px difference trick (avoid using a 1% difference for this, it can be unreliable) to smoothen things for radial and linear ones (the conic gradient case is a lot more complicated though and I haven’t been able to find a pure CSS solution that doesn’t involve emulating the conic gradient with a linear one).

But what can we do about it in the case of steps created via an SVG filter?

Given the introduction of the 1px difference produces an effect similar to a tiny blur, the first instinct would be to try to blur the whole thing. However, the result looks terrible, even when we correct the edge alpha decrease, so the blur idea goes out the window!

We could also smoothen the edges of each step using a technique similar to the no matrix filter gooey effect. That mostly works, save for a bit of weird rounding at the edges for all gradients and in the middle of the conic one. But that’s a lot of filter primitives, a lot for such a tiny visual gain.

Another option would be to try to simplify this technique and smoothen the edges of even steps – this avoids increasing the number of primitives with the number of steps, but also comes with other technical challenges. So at the end of the day, it’s another path I’m not fully convinced it’s worth taking for such a small visual gain. Not to mention the weird edge rounding and the even more obvious clump in the middle of the conic-gradient().

Finally, we could make the gradients grainy. But the approach discussed in a previous article is likely not what we’re going for.

There may be cases where it is what we want, for example when it comes to such dithered band cards:

Most of the time, this is probably not the desired result. So maybe try another approach to grainy gradients, one that doesn’t use displacement maps and also doesn’t alter the gradient palette?

We could use the old approach of layering and blending with a desaturated noise layer whose alpha we also reduce to a certain extent before blending:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' x='0' y='0' width='1' height='1' 
          color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.713' numOctaves='3'/>
    <feColorMatrix type='saturate' values='0'/>
    <feComponentTransfer>
      <feFuncA type='linear' slope='.6'/>
    </feComponentTransfer>
    <feBlend in2='SourceGraphic' mode='overlay'/>
  </filter>
</svg>

Here, we fully desaturate the noise produced by feTurbulence and then scale down its alpha (to .6 of what it would be otherwise).

This is the path taken by the sunburst demo below, which was created taking inspiration from the heading image of an earlier post here:

This comes with the disadvantage of altering the original palette, but if that’s not as much of an issue, it could work.

Finally, another option would be XOR-ing the alpha of the desaturated and reduced alpha noise layer and the alpha of the steps:

]]>
https://frontendmasters.com/blog/step-gradients-with-a-given-number-of-steps/feed/ 0 6338
Grainy Gradients https://frontendmasters.com/blog/grainy-gradients/ https://frontendmasters.com/blog/grainy-gradients/#comments Fri, 13 Jun 2025 14:37:36 +0000 https://frontendmasters.com/blog/?p=6066 You know when you set a background gradient or a gradient mask and you get an ugly banding effect? If you can’t picture what I mean, here’s an example:

A left to right pinkinsh orange to dark grey gradient exhibiting banding.
 example gradient with banding

Previous Solutions

Over time, I’ve seen a couple of approaches commonly recommended for solving this. The first is to simply introduce more stops (gradient “easing”), which I’m not really keen on doing, even if I can just generate them in a Sass loop and never need to know about them. The second one is to make the gradient noisy. Let’s do that.

The way I first went about making gradients grainy was to have a gradient layer and a noise layer (using pseudo-elements for the layers) and then blend them together. I first did this in response to a question asked on X. That video became one of my most watched ones ever, which isn’t something I’m happy about anymore because I’ve come to find that technique to be overly complicated, like scratching behind the right ear with the left foot.

A few months later, I saw an article that was doing something similar: placing a gradient layer and a noise layer one on top of the other. Unlike my approach, it wasn’t blending the two layers and instead was relying on one end of the gradient being transparent to allow the noise to show through. For the other end to be something other than transparent, it would layer an overlay and blend it. Just like my layered pseudos approach… too complicated! Not to mention that the contrast() and brightness() tampering (meant to highlight the grain) make this only work for certain gradient inputs and they greatly alter the saturation and luminosity of the original gradient palette.

In time, I would improve upon my initial idea and, almost half a decade later, I would make a second video on the topic, presenting a much simplified technique. Basically, the gradient would get fed into an SVG filter, which would generate a noise layer, desaturate it and then place it on top of the input gradient. No external files, no base64-ing anything, no separate (pseudo)element layers for the noise and the gradient.

Still, it didn’t take long before I wasn’t happy with this solution anymore, either.

The big problem with layering the noise and the gradient

The problem with all of these solutions so far is that they’re changing the gradient. Depending on the particular technique we use, we always end up with a gradient that’s either darker, brighter, or more saturated than our original one.

We can reduce the noise opacity, but in doing so, our gradient becomes less grainy and the efficiency of fixing banding this way decreases.

A better solution

How about not layering the the noise layer and instead using it as a displacement map?

What this does is use two of the four RGBA channels of the noise layer to determine how the individual pixels of the input gradient are shifted along the x and y axes.

Both the filter input (our gradient) and the noise layer can be taken to be 2D grids of pixels. Each pixel of our input gradient gets displaced based on the two selected channel values of its corresponding pixel in the noise layer (used as a displacement map).

A channel value below 50% means moving in the positive direction of the axis, a channel value above 50% means moving in the negative direction of the axis and a channel value of exactly 50% means not moving at all.

The displacement formula for a generic channel value of C and a displacement scale of S is the following:

(.5 - C)*S

If we use the red channel R for displacement along the x axis and the alpha channel A for displacement along the y axis, then we have:

dx = (.5 - R)*S
dy = (.5 - A)*S

Note that the values for both R and A are in the [0, 1] interval (meaning channel values are zeroed at 0 and maxed out at 1), so the difference between the parenthesis is in the [-.5, .5] interval.

The bigger the scale value S is, the more the gradient pixels mix along the gradient axis depending on the red R and alpha A channel values of the displacement map generated by feTurbulence.

Let’s see our code!

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
  </filter>
</svg>

Since the <svg> element is only used to hold our filter (and the only thing a filter does is apply a graphical effect on an already existing element), it is functionally the same as a <style> element, so we zero its dimensions and hide it from screen readers using aria-hidden. And, in the CSS, we also take it out of the document flow (via absolute or fixed positioning) so it doesn’t affect our layout in any way (which could happen otherwise, even if its dimensions are zeroed).

svg[height='0'][aria-hidden='true'] { position: fixed }

The <filter> element also has a second attribute beside its id. We aren’t going into it here because I don’t really understand it myself. Just know that, in order to get our desired result cross-browser, we always need to set this attribute to sRGB whenever we’re doing anything with the RGB channels in the filter. The sRGB value isn’t the default one (linearRGB is), but it’s the one we likely want most of the time and the only one that works properly cross-browser.

The feTurbulence primitive creates a fine-grained noise layer. Again, we aren’t going into how this works in the back because I haven’t been able to really understand any of the explanations I’ve found or I’ve been recommended for the life of me.

Just know that the baseFrequency values (which you can think of as being the number of waves per pixel) need to be positive, that integer values produce just blank and that bigger values mean a finer grained noise. And that numOctaves values above the default 1 allow us to get a better-looking noise without having to layer the results of multiple feTurbulence primitives with different baseFrequency values. In practice, I pretty much never use numOctaves values bigger than 3 or at most 4 as I find above that, the visual gain really can’t justify the performance cost.

We also switch here from the default type of turbulence to fractalNoise, which is what’s suited for creating a noise layer.

This noise is then used as a displacement map (the second input, in2, which is by default the result of the previous primitive, feTurbulence here, so we don’t need to set it explicitly) for the filter input (SourceGraphic). We use a scale value of 150, which means that the maximum an input pixel can be displaced by in either direction of the x or y axis is half of that (75px) in the event the channel used for x or y axis displacement is either zeroed (0) or maxed out (1) there. The channel used for the y axis displacement is the default alpha A, so we don’t need to set it explicitly, we only set it for the x axis displacement.

We’re using absolute pixel displacement here, as relative displacement (which requires the primitiveUnits attribute to be set to objectBoundingBox on the <filter> element) is not explicitly defined in the spec, so Chrome, Firefox and Safari each implement it in a different way from the other two for non-square filter inputs. I wish that could be a joke, but it’s not. This is why nobody really uses SVG filters much — a lot about them just doesn’t work. Not consistently across browsers anyway.

At this point, our result looks like this:

Grainy gradient with dithered edges. A bright pink outline shows us the boundary of the filter input. Within this boundary, we have transparent pixels. Outside it, we have opaque pixels.

Not quite what we want. The dashed bright pink line shows us where the boundary of the filter input gradient box was. Along the edges, we have both transparent pixels inside the initial gradient box and opaque pixels outside the initial gradient box. Two different problems, each needing to get fixed in a different way.

To cover up the transparent pixels inside the initial gradient box, we layer the initial gradient underneath the one scrambled by feDisplacementMap. We do this using feBlend with the default mode of normal (so we don’t need to set it explicitly), which means no blending, just put one layer on top of the other. The bottom layer is specified by the second input (in2) and in our case, we want it to be the SourceGraphic. The top layer is specified by the first input (in) and we don’t need to set it explicitly because, by default, it’s the result of the previous primitive (feDisplacementMap here), which is exactly what we need in this case.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

I’ve seen a lot of tutorials using feComposite with the default operator of over or feMerge to place layers one on top of another, but feBlend with the default mode of normal produces the exact same result, I find it to be simpler than feMerge in the case of just two layers and it’s fewer characters than feComposite.

To get rid of the opaque pixels outside the initial gradient box, we restrict the filter region to its exact input box — starting from the 0,0 point of this input and covering 100% of it along both the x and y axis (by default, the filter region starts from -10%,-10% and covers 120% of the input box along each of the two axes). This means explicitly setting the x, y, width and height attributes:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB' 
	  x='0' y='0' width='1' height='1'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713' numOctaves='4'/>
    <feDisplacementMap in='SourceGraphic' scale='150' xChannelSelector='R'/>
    <feBlend in2='SourceGraphic'/>
  </filter>
</svg>

Another option to get rid of this second problem would be to use clip-path: inset(0) on the element we apply this grainy filter on. This is one situation where it’s convenient that clip-path gets applied after filter (the order in the CSS doesn’t matter here).

.grad-box {
  background: linear-gradient(90deg, #a9613a, #1e1816);
  clip-path: inset(0);
  filter: url(#grain)
}
Grainy gradient with sharp edges, no tansparent pixels within, no opaque pixels outside.
the desired result

A problem with this solution

The inconvenient part about this filter is that it applies to the entire element, not just its gradient background. And maybe we want this element to also have text content and a box-shadow. Consider the case when before applying the filter we set a box-shadow and add text content:

Card with a banded gradient, text and box-shadow.
the case when we also have a shadow and text

In this case, applying the filter to the entire element causes all kinds of problems. The text “dissolves” into the gradient, the black box-shadow outside the box has some pixels displaced inside the box over the gradient – this is really noticeable in the brighter parts of this gradient. Furthermore, if we were to use the clip-path fix for the gradient pixels displaced outside the initial gradient box, this would also cut away the outer shadow.

Previous card with a banded gradient, text and box-shadow, now with a filter pplied on it too. This has unpleasant side effects as dscribed above.
problems arising when we apply the grainy filter on the entire element

The current solution would be to put this gradient in an absolutely positioned pseudo behind the text content (z-index: -1), covering the entire padding-box of its parent (inset: 0). This separates the parent’s box-shadow and text from the gradient on the pseudo, so applying the filter on the pseudo doesn’t affect the parent’s box-shadow and text.

.grad-box { /* relevant styles */
  positon: relative; /* needed for absolutely positioned pseudo */
  box-shadow: -2px 2px 8px #000;
	
  &::before {
    position: absolute;
    inset: 0;
    z-index: -1;
    background: linear-gradient(90deg, #a9613a, #1e1816);
    filter: url(#grain);
    clip-path: inset(0);
    content: '' /* pseudo won't show up without it */
  }
}
Previous card with a gradient, text and box-shadow, except now the gradient is grain, which fixes the banding issue.
the desired result when having a shadow and text content (live demo)

Improving things for the future

While this works fine, it doesn’t feel ideal to have to use up a pseudo we might need for something else and, ugh, also have to add all the styles for positioning it along all three axes (the z axis is included here too because we need to place the pseudo behind the text content).

And we do have a better option! We can apply the filter only on the gradient background layer using the filter() function.

This is not the same as the filter property! It’s a function that outputs an image and takes as arguments an image (which can be a CSS gradient too) and a filter chain. And it can be used anywhere we can use an image in CSS — as a background-imageborder-imagemask-image… even shape-outside!

In our particular case, this would simplify the code as follows:

.grad-box { /* relevant styles */
  box-shadow: -2px 2px 8px #000;
  background: filter(linear-gradient(90deg, #a9613a, #1e1816), url(#grain));
}

Note that in this case we must restrict the filter region from the <filter> element attributes, otherwise we run into a really weird bug in the one browser supporting this, Safari.

Safari problem: it's trying to fit the filter output, including what goes outside the input image box, into the input image box, basically scaling down the image to make room for its pixels displaced outside its initial boundary by the filter.
the Safari problem when we don’t restrict the filter region

Because, while Safari has supported the filter() function since 2015, for about a decade, sadly no other browser has followed. There are bugs open for both Chrome and Firefox in case anyone wants to show interest in them implementing this.

Here is the live demo, but keep in mind it only works in Safari.

This would come in really handy not just for the cases when we want to have text content or visual touches (like box-shadow) that remain unaffected by the noise filter, but especially for masking. Banding is always a problem when using radial-gradient() for a mask and, while we can layer multiple (pseudo)elements instead of background layers and/ or borders, masking is a trickier problem.

For example, consider a conic spotlight. That is, a conic-gradient() masked by a radial one. In this case, it would really help us to be able to apply a grain filter directly to the mask gradient.

.conic-spotlight {
  background: 
    conic-gradient(from 180deg - .5*$a at 50% 0%, 
                   $side-c, #342443, $side-c $a);
  mask: 
    filter(
      radial-gradient(circle closest-side, red, 65%, #0000), 
      url(#grain))
}

In this particular case, the grain filter is even simpler, as we don’t need to layer the non-grainy input gradient underneath the grainy one (so we ditch that final feBlend primitive). Again, remember we need to restrict the filter region from the <filter> element attributes.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='grain' color-interpolation-filters='sRGB' x='0' y='0' width='1' height='1'>
    <feTurbulence type='fractalNoise' baseFrequency='.9713'/>
    <feDisplacementMap in='SourceGraphic' scale='40' xChannelSelector='R'/>
  </filter>
</svg>

Here is the live demo. Keep in mind it only works in Safari.

Since we can’t yet do this cross-browser, our options depend today on our constraints, the exact result we’re going for.

Do we need an image backdrop behind the spotlight? In this case, we apply the radial mask on the .conic-spotlight element and, since, just like clip-path, mask gets applied after filter, we add a wrapper around this element to set the filter on it. Alternatively, we could set the conic spotlight background and the radial mask on a pseudo of our .conic-spotlight and set the filter on the actual element.

.conic-spotlight {
  display: grid;
  filter: url(#grain);
	
  &::before {
    background: 
      conic-gradient(from 180deg - .5*$a at 50% 0%, 
                     $side-c, #342443, $side-c $a);
    mask: radial-gradient(circle closest-side, red, 65%, #0000);
    content: ''
  }
}

If however we only need a solid backdrop (a black one for example), then we could use a second gradient layer as a radial cover on top of the conic-gradient():

body { background: $back-c }

.conic-spotlight {
  background:
    radial-gradient(circle closest-side, #0000, 65%, $back-c), 
    conic-gradient(from 180deg - .5*$a at 50% 0%, 
                   $side-c, #342443, $side-c $a);
  filter: url(#grain)
}

Note that neither of these two emulate the Safari-only demo exactly because they apply the grain filter on the whole thing, not just on the radial-gradient() (which allows us to get rid of the mask banding, but preserve it for the conic-gradient() to give the radiating rays effect). We could tweak the second approach to make the cover a separate pseudo-element instead of a background layer and apply the grain filter just on that pseudo, but it’s still more complicated than the filter() approach. Which is why it would be very good to have it cross-browser.

Some more examples

Let’s see a few more interesting demos where we’ve made visuals grainy!

Grainy image shadows

a grid of square images, each with a grainy shadow that's a blurred copy of itself
realistic grainy image shadows

Shadows or blurred elements can also exhibit banding issues where their edges fade. In this demo, we’re using a slightly more complex filter to first blur and offset the input image, then using the feTurbulence and feDisplacementMap combination to make this blurred and offset input copy grainy. We also decrease its alpha a tiny little bit (basically multiplying it with .9). Finally, we’re placing the original filter input image on top of this blurred, offset, grainy and slightly faded copy.

- let d = .1;

svg(width='0' height='0' aria-hidden='true')
  filter#shadow(x='-100%' y='-100%' width='300%' height='300%'
                color-interpolation-filters='sRGB'
                primitiveUnits='objectBoundingBox')
    //- blur image
    feGaussianBlur(stdDeviation=d)
    //- then offset it and save it as 'in'
    feOffset(dx=d dy=d result='in')
    //- generate noise
    feTurbulence(type='fractalNoise' baseFrequency='.9713')
    //- use noise as displacement map to scramble a bit the blurred & offset image
    feDisplacementMap(in='in' scale=2*d xChannelSelector='R')
    //- decrease alpha a little bit
    feComponentTransfer
      feFuncA(type='linear' slope='.9')
    //- add original image on top
    feBlend(in='SourceGraphic')

Since our input images are square here, we can use relative length values (by setting primitiveUnits to ObjectBoundingBox) and still get the same result cross-browser. A relative offset of 1 is equal to the square image edge length, both for the dx and dy attributes of feOffset and for the scale attribute of feDisplacementMap.

In our case, the dx and dy offsets being set to .1 means we offset the blurred square image copy by 10% of its edge length along each of the two axes. And the displacement scale being set to .2 means any pixel of the blurred and offset copy may be displaced by at most half of that (half being 10% of the square image edge), with plus or with minus, along both the x and y axes. And it gets displaced by that much when the selected channel (given by xChannelSelector and yChannelSelector) of the corresponding map pixel is either zeroed (in which case it’s displaced in the positive direction) or maxed out (negative displacement).

The shadow doesn’t need to be a copy of the input image, it can also be a plain rectangle:

<svg width='0' height='0' aria-hidden='true'>
  <filter id='shadow' x='-50%' y='-50%' width='200%' height='200%'
          color-interpolation-filters='sRGB'
          primitiveUnits='objectBoundingBox'>
    <!-- flood entire filter region with orangered -->
    <feFlood flood-color='orangered'/>
    <!-- restrict to rectangle of filter input (our image)  -->
    <feComposite in2='SourceAlpha' operator='in'/>
    <!-- blur and everything else just like before  -->
  </filter>
</svg>

Grainy image fade

This is pretty similar to the previous demo, except what we displace are the semi-transparent fading edge pixels obtained using a blur. And we obviously don’t layer the original image on top.

There are a couple more little tricks used here to get things just right, but they’re outside the scope of this article, so we’re not going into them here.

Noisy gradient discs

These are created with SVG <circle> elements just so we can use SVG radial gradients for them. Compared to CSS radial-grdient(), SVG radialGradient has the advantage of allowing us to specify a focal point (via fx and fy), which allows us to create radial gradients not possible with pure CSS.

The filter is a bit more complex here because the aim was to create a specific type of noise, but the main idea is the same.

Animated single img gradient glow border

a grid of images with total or partial gradient borders, each having a glow, which is a grainy glow for every second image
screenshot (live demo)

Animated gradient glow borders seem to be all the rage nowadays, which is something I never imagined woukd happen when I first started playing with them almost a decade ago. But wherever there’s a fade effect like a glow, we may get banding. It’s pretty subtle in this case, but the grainy glow looks better than the no grain version.

Grainy CSS backgrounds

Another example would be this one, where I’m layering a bunch of linear gradients along the circumradii to the corners of a regular polygon in order to emulate a mesh gradient. Even when blending these gradients, subtle banding is still noticeable. Applying our standard grain filter discussed earlier fixes this problem.

Also, since we’re using clip-path to get the polygon shape and this is applied after the filter, we don’t need to worry about opaque pixels displaced outside the polygon shape by our grain filter. This means we don’t need to bother with setting the filter region via the <filter> element attributes.

Grainy SVG backgrounds

The idea here is we layer a bunch of different SVG shapes, give them various fills (plain, linearGradient or radialGradient ones), blur them and then finally apply a grain filter.

a 3⨯3 grid of grainy abstract backgrounds
grainy SVG backgrounds (live demo)
]]>
https://frontendmasters.com/blog/grainy-gradients/feed/ 4 6066
ChatGPT and the proliferation of obsolete and broken solutions to problems we hadn’t had for over half a decade before its launch https://frontendmasters.com/blog/chatgpt-and-old-and-broken-code/ https://frontendmasters.com/blog/chatgpt-and-old-and-broken-code/#comments Tue, 20 May 2025 15:57:01 +0000 https://frontendmasters.com/blog/?p=5808 It was a lovely day on the internet when someone asked how to CSS animated gradient text like ChatGPT’s “Searching the web” and promptly got an answer saying “Have you tried asking ChatGPT? Here’s what it told me!” – well, maybe not these exact words, but at least it rhymes.

Both the question and this answer have since been deleted. But we still have the chat link that got posted in the answer and we’re going to look into it.

Screenshot of ChatGPT Generated Code
screenshot of the code produced by ChatGPT

This is the code that ChatGPT spat out in text format:

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8">
    <title>Text Color Loading Animation</title>
    <style>
      body {
        background-color: #111;
        display: flex;
        justify-content: center;
        align-items: center;
        height: 100vh;
      }

      .loading-text {
        font-size: 3rem;
        font-weight: bold;
        background: linear-gradient(90deg, #00f, #0ff, #00f);
        background-size: 200% auto;
        background-clip: text;
        -webkit-background-clip: text;
        color: transparent;
        -webkit-text-fill-color: transparent;
        animation: shimmer 2s linear infinite;
      }

      @keyframes shimmer {
        0% {
          background-position: -100% 0;
        }
        100% {
          background-position: 100% 0;
        }
      }
    </style>
  </head>
  <body>
    <div class="loading-text">Loading...</div>
  </body>
</html>

Now you may be thinking: what’s the problem with this code, anyway? If you copy-paste into CodePen, it does produce the desired result, doesn’t it?

Well, we also get the exact same result if we replace this CSS:

.loading-text {
  font-size: 3rem;
  font-weight: bold;
  background: linear-gradient(90deg, #00f, #0ff, #00f);
  background-size: 200% auto;
  background-clip: text;
  -webkit-background-clip: text;
  color: transparent;
  -webkit-text-fill-color: transparent;
  animation: shimmer 2s linear infinite;
}

@keyframes shimmer {
  0% {
    background-position: -100% 0;
  }
  100% {
    background-position: 100% 0;
  }
}

with this CSS:

.loading-text {
  font-size: 3rem;
  font-weight: bold;
  background: linear-gradient(90deg, #00f, #0ff, #00f) -100%/ 200%;
  -webkit-background-clip: text;
          background-clip: text;
  color: transparent;
  animation: shimmer 2s linear infinite;
}

@keyframes shimmer {
  to {
    background-position: 100%;
  }
}

You might think the ChatGPT solution includes some fallbacks that maybe make it work in more browsers. You’d be wrong. There are exactly zero browsers in which the ChatGPT solution isn’t broken, but the alternative above is. Zero! Not a single one. In all of the (very old) browsers where the alternative above breaks, the ChatGPT solution breaks too.

The history of gradient text solutions and what ChatGPT gets wrong

Let’s go some 15 years back in time. I discovered CSS in August 2009, just as it was getting new shiny features like transforms and gradients. One of the first tricks I came across online in early 2010 was precisely this — creating image text in general and CSS gradient text in particular.

The declaration I ditched completely from what ChatGPT generated was:

-webkit-text-fill-color: transparent;

This answer only included -webkit-text-fill-color, though I’ve seen versions of this circulating online that use:

-webkit-text-fill-color: transparent;
        text-fill-color: transparent;

There is no such thing as text-fill-color. There isn’t even a standard spec for it. It’s just something that was implemented in WebKit with a prefix almost 2 decades ago and then became used enough on the web that other browsers had to support it too. With the -webkit- prefix. So the prefixed version is the only one that has ever been implemented in any browser.

While WebKit introduced this property alongside -webkit-text-stroke, its usage would end up far exceeding that of -webkit-text-stroke. This is something that started about 15 years ago when -webkit-text-fill-color became a common tactic for making text transparent only in WebKit browsers. Precisely for image text in general and then a lot more often for gradient text in particular.

At the time, clipping backgrounds to text was only supported in WebKit browsers via the (back then) non-standard -webkit-background-clip: text. If we wanted to get a visible gradient text this way, we had to be able to see through the actual text on top of the clipped background. Problem was, back in 2010, if we set color to transparent, then we’d get:

  • Gradient text as desired in browsers supporting both -webkit-background-clip: text and CSS gradients
  • No visible text and a gradient rectangle in browsers supporting CSS gradients, but not -webkit-background-clip: text (in 2010, this would have been the case for Firefox)
  • Nothing visible at all in browsers not supporting CSS gradients (remember the first IE version to support CSS gradients was IE10, which only came out in 2012; plus Opera still had its own engine back then and wouldn’t get CSS gradients for another year)
Visual representation of the three cases described above.
the state of things in 2010

So given the only browsers we could get gradient text in at the time were WebKit browsers, it made sense to restrict both setting a gradient background and making the text transparent to WebKit browsers. At the time, all gradients were prefixed, so that made the first part easier. For the second part, the solution was to use -webkit-text-fill-color to basically override color just for WebKit browsers.

So to get an aqua to blue gradient text with a decent in-between blue text fallback in non-supporting browsers, the code we had to write in 2010 looked like this:

color: #07f;
background: 
  -webkit-gradient(linear, 0 0, 100% 0, 
    color-stop(0, #0ff), color-stop(1, #00f));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent

Note that at the time, this was seen as a temporary WebKit-only solution, as other options for getting such gradient text were being discussed. The “temporary” solution stuck, I guess. And so it’s now in the spec.

And yes, that is a different gradient syntax (which, by the way, is still supported to this day in Chrome). The syntax for CSS gradients went through multiple iterations. After this one, we had another -webkit- prefixed version, a lot more similar to what we have today, but still not the same. If you’re curious, you can see a bit more about the support timeline in my 10 year old rant about using gradient generators without understanding the code they spit out and without having a clue how much of it is really necessary. Because before we had AI chatbots spitting out too much needless CSS for the desired result, we had all these CSS3 generators doing pretty much the same! Like a song says, oh, it was different… and yet the same!

Using -webkit-text-fill-color to override color just for WebKit browsers, we got:

Visual representation of possible results in 2010, when using -webkit-text-fill-color: transparent to allow seeing through the text in WebKit browsers. If all -webkit- prefixed properties were supported, we got a left to right, aqua to blue gradient text. If none were supported, we got an in-between blue fallback. If -webkit-background-clip: text was not supported while -webkit-gradient and -webkit-text-fill-color were, then we got no visible text on a left to right, aqua to blue gradient rectangle. If -webkit-text-fill-color was supported and set the text to transparent, but -webkit-gradient wasn't, then nothing would show up. In this final case, the support status for -webkit-background-clip: text was irrelevant.
the state of things in 2010

Better, I guess, but not ideal.

Since support for -webkit-text-fill-color came before for CSS gradients, that left a gap where the text would be made transparent, but there would be no gradient to be clipped to (making the support status for -webkit-background-clip: text irrelevant). In this case, there would be no visible text and no gradient… just nothing but empty space.

I first thought that anyone who’d be using one of these browsers that would always be the first to support new and shiny things would also care about keeping them updated, right? Right?

Wrong! Some months later, I went to a job interview. I’ve always been a show-off, I’m like a fish in the water when live coding, so I jumped on the chance to actually get in front of a computer and impress with all the cool things I could do thanks to the new CSS features. They were using a Chrome version that was quite a bit behind the then current one. CSS gradients did not work.

I started using Modernizr to test for CSS gradient support after that. We didn’t have @supports back then, so Modernizr was the way to go for many years.

Then there was the Android problem – CSS gradients being supported, but not -webkit-background-clip: text, though support tests returned false positives. Since I didn’t have a smartphone and I don’t think I even knew anyone who had one at the time, I couldn’t test if any fix found on the internet would work and I don’t recall anyone ever complaining, so I confess I never even bothered with trying to cover this case.

Then things started to get even funnier.

In 2011, WebKit browsers started supporting a newer, different gradient syntax. Still with a prefix, still different from the standard one. Most notably, angles were going in the opposite direction and the gradient start was offset by 90° relative to the current version, meaning the  gradient start was taken to be at 3 o’clock, rather than at 12 o’clock like in the case of the current standard one.

This really caught on. Prefixes were meant to solve a problem, but a lot of developers skipped reading the instructions on the box, so they wrote code with either too many prefixed versions of the same properties or, more commonly, not enough… usually just the WebKit one. Which meant other browsers started considering supporting the -webkit- prefix too.

There was a lot written about it at the time, but basically what this meant was that Opera first implemented -webkit- prefixes in 2012 and then switched away from Presto altogether a year later, at around the same time Blink was announced. Another year later, IE added the -webkit- prefix too. This would carry over to Edge and then Edge would also move to Blink. And then in 20152016, Firefox also implemented some non-standard -webkit- prefixed properties and mapped some other -webkit- prefixed properties to their -moz- or standard equivalents.

What this meant was that for a while, we had support for -webkit- prefixed gradients in non-WebKit browsers… but not support for -webkit-text-fill-color-webkit-background-clip: text, so the following code:

color: #07f;
background: -webkit-linear-gradient(left, #0ff, #00f);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent

would for example produce a mid blue text on an aqua to blue gradient rectangle in Opera in-between starting to support the -webkit- prefix and switching to Blink. Not very readable, right?

The text 'Loading...' in an in-between blue on top of a left to right, aqua to blue gradient. The contrast between the text and the background is poor. It could at most pass AA for large text (above 18pt or bold above 14pt) and AA for user interface components and graphical objects on the left end, but would fail WCAG 2.0 and 2.1 everywhere else.
bad contrast

So the solution for that was to add another fully transparent -o- prefixed gradient after the -webkit- one:

color: #07f;
background: -webkit-linear-gradient(left, #0ff, #00f);
background: -o-linear-gradient(transparent, transparent);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent

Quite the mess indeed.

Since 2016, all browsers except Opera Mini have supported clipping backgrounds to text. Given the lack of support for clipping backgrounds to text was why we avoided setting color to transparent, what’s the point of still using -webkit-text-fill-color now?

You might say support. Indeed, there are people stuck on old browsers without the option to update because they are stuck on old operating systems which they cannot upgrade because of the age of hardware. I live in Romania and not only do I know people still using Windows XP on computers from the ’90s, I have seen that some public institutions still use Windows XP too. Newer browser versions don’t work there… but even so, the last browser versions that support Windows XP all support clipping backgrounds to text. And with automatic updates, all I’ve seen have been warnings about being unable to update to a newer version, not browsers stuck even further back.

Even if they were that far back, the ChatGPT solution sets color to transparent alongside -webkit-text-fill-color. The whole point of using -webkit-text-fill-color before clipping backgrounds to text became cross-browser was to avoid setting color to transparent, which ChatGPT isn’t doing because it’s dumping that declaration in there too. So it’s not improving support, it’s just adding redundant code. Or breaking the solution for a problem that had become obsolete over half a decade before ChatGPT was launched. Whichever you prefer.

In any case, the ChatGPT code is what we call “struţocămilă” in Romanian – an impossible animal that’s half ostrich, half camel.

A strange hybrid animal with the body of an ostrich and the head and neck of a camel.
even when it came to creating this wonder, AI couldn’t do a better job than a human in Photoshop

Not to mention today we have something far better for handling such situations: @supports!

I get the extending support argument for not setting background-clip: text in the shorthand or even unprefixed. Though now supported unprefixed and in the shorthand in the current versions of all major desktop and mobile browsers, support doesn’t extend to the last browser versions on Windows XP, 7 or 8.

That being said, if manually including both the prefixed and standard versions of a property, please always include the unprefixed one last. There are very rare situations where having the prefixed version of a property override the standard one might make sense for getting around some weird bug, but that’s definitely not the case here.

On background-size

Another thing you may have noticed is the ChatGPT solution sets background-size separately, not in the shorthand and that it uses two values, not just one, the second one being auto.

There is a historical (although also completely pointless today) reason behind this too. 15 years ago, when we first got this pure CSS gradient text technique, most browsers didn’t yet support setting background-size in the shorthand. So it was set separately. But that has not been an issue for over a decade.

Another issue with background-size was that initially, WebKit browsers implemented an earlier draft of the spec where a missing second value was taken to be equal to the first value, rather than auto, which in the case of gradients means the height of the box specified by background-origin (by default the padding-box). However, in the case of a horizontal gradient like the one we have here, the second background-size value is completely irrelevant, regardless of the background-position. Whether it’s 200% (duplicating the first value) or auto (100% of the padding-box height in this case), the visual result is always exactly the same for horizontal gradients.

So there is no reason why setting it in the shorthand with a single value wouldn’t produce the exact same result. And that has been the case for over a decade.

Finessing things

Similar to how we can omit the second background-size value, we can also omit it for background-position. The default is 50%, but any other value produces the exact same result in the case of any gradient covering the entire height of the background-origin box. And in the case of a horizontal gradient like we have here, it wouldn’t matter even if we had a different background-size height.

We can also easily omit one of the two end keyframes and set its background-position in the shorthand. Then the missing end keyframe gets generated out of there. This is not a new feature, I’ve been using this for over a decade.

Then I’m not a fan of those body styles. Firstly, without zeroing its default margin, setting its height to 100vh creates a scrollbar. Secondly, it can also be problematic even when setting margin: 0. Just don’t do it and do this instead, it has better support than dvh:

html, body { display: grid }

html { height: 100% }

body { background: #222 }

.loading-text { place-self: center }

Finally, default fonts might be ugly, so let’s go for a prettier one. We could also make it scale with the viewport within reasonable limits.

font: 900 clamp(2em, 10vw, 10em) exo, sans-serif

Here’s a CodePen demo:

This is not just ChatGPT

ChatGPT is in the title because it was what got used in this case. But this “dump in old popular solutions and sprinkle in some modern CSS to create a grotesque hybrid” is not unique to ChatGPT.

I saw Gemini spit out this monstruosity (that doesn’t even produce the desired result) just a day earlier:

screenshot

It makes sense. Older solutions have had more time to become more popular and they’re often at the top in search results too. But that doesn’t necessarily mean they’re still the best choice today. At least when you’re looking at an article or a Stack Overflow answer, you can check the date. An AI solution might link to resources (and in one instance, I discovered it was my own 12-year old, obsolete StackOverflow answer that was being referenced), but if it doesn’t or if you don’t check the resources, then you can never know just how outdated a technique might be. Or how badly it got messed up on the way.

]]>
https://frontendmasters.com/blog/chatgpt-and-old-and-broken-code/feed/ 6 5808
Using Container Query Units Relative to an Outer Container https://frontendmasters.com/blog/using-container-query-units-relative-to-an-outer-container/ https://frontendmasters.com/blog/using-container-query-units-relative-to-an-outer-container/#respond Tue, 06 May 2025 23:53:28 +0000 https://frontendmasters.com/blog/?p=5761 Recently, Matt Wilcox posted on Mastodon:

The fact you can’t specify which container for container query units is a ballache. The moment you have nested containers you’re [screwed]; because if you want the calculated gap from the row’s container; but you’re inside a nested container… tough. Your units are wrong. And you can’t just say “no; not relative to this container; relative to the named outer container!”

First off, if you’re not familiar with container queries and container query units, you can check out one of the many resources on the topic, for example this interactive guide by Ahmad Shadeed, which I believe is the most recent out of all the detailed ones I’ve seen. As always, the date of the resources used is important for web stuff, especially since these units in particular have changed their name since they were first proposed and we got an early draft of the spec.

Now, the problem at hand: let’s say we have an .inner-container inside an .outer-container – they are both made to be containers:

[class*='container'] { container-type: size }

We want any .inner-child of the .inner-container to be able to use length values set in container query units relative to the .outer-container (more precisely, to its content-box dimensions). The problem is, if we do something like this (a 20cqw light blue strip at the start of the gradient going towards 3 o’clock):

.inner-child {
  background: linear-gradient(90deg, #0a9396 20cqw, #0000)
}

… then the 20cqw value is 20% (a fifth) of the content-box width of the .inner-container. This can be seen below, where we have purple guidelines 20% of the width apart.

Screenshot illustrating how a background sized to cqw on the child of the inner container is a fifth of the inner container's width.
what 20cqw represents

But what we want is for that 20cqw value to be 20% of the content-box width of the .outer-container.

Strictly for the queries themselves, we could do something like this:

.outer-container { container: outer/ size }
.inner-container { container: inner/ size }

@container outer (min-width: 500px) {
  .inner-child { background: darkorange }
}

This allows us to set certain styles on the .inner-child elements based on where the width of the .outer-container (which isn’t the nearest container for .inner-child) is situated relative to the 500px threshold.

But we cannot do something like this to specify which container should be the one that the query units used on .inner-child are relative to:

.inner-child {
  /* can't do this */
  background: linear-gradient(90deg, #0a9396 outer 20cqw, #0000)
}

Nor can we do this:

.inner-child {
  /* can't do this either */
  --s: outer 20cqw;
  background: linear-gradient(90deg, #0a9396 var(--s), #0000)
}

However, we are getting closer!

What if we move the --s variable uspstream? After all, a 20cqw length value set on the .inner-container is 20% of the content-box width of its nearest container, which is the .outer-container. This would mean our code becomes:

[class*='container'] { container-type: size }

.inner-container {
  --s: 20cqw;
  background: 
    repeating-linear-gradient(45deg, #bb3e03 0 5px, #0000 0 1em) 
      0/ var(--s) no-repeat
}

.inner-child {
  background: 
    linear-gradient(90deg, #0a9396cc var(--s), #0000)
}

We also give the .inner-container a similar background restricted to 20cqw from the left along the x axis and make the .inner-child semi-transparent, just to check if the --s values overlap (which is what we want, --s being 20% or a fifth of the .outer-container width). However, this fails, as it can be seen below:

Screenshot. Both the inner container and its child have a background sized to 20cqw. However, the container query units are relative to the outer container only for the inner container, the container query units used on its child being still relative to the inner container (one fifth of its content-box width).
screenshot of result

For the .inner-container the 20cqw of the --s is taken to be 20% of the content-box width of its nearest container, .outer-container (dashed dark blue boundary). However, for the .inner-child, the 20cqw of the --s aren’t taken to mean the same value. Instead, they are taken to mean 20% of the .content-box width of the .inner-container (dotted dark red boundary).

Boo!

But what happens if we also register --s?

@property --s {
  syntax: '<length>';
  initial-value: 0px;
  inherits: true
}

Bingo, this works!

Screenshot. Both the inner container and its child have a background sized to 20cqw, the container query units being relative to the outer container.
desired result

I hope you’ve enjoyed this little trick.

Where would you use this?

]]>
https://frontendmasters.com/blog/using-container-query-units-relative-to-an-outer-container/feed/ 0 5761
A Deep Dive into the Inline Background Overlap Problem https://frontendmasters.com/blog/overlapping-inline-backgrounds/ https://frontendmasters.com/blog/overlapping-inline-backgrounds/#comments Tue, 18 Mar 2025 17:54:44 +0000 https://frontendmasters.com/blog/?p=5330 tweet by Lucas Bonomi got me thinking about this problem: how to get a semitransparent background following some inline text with padding, but without the overlap problem that can be seen in the image below.

Screenshot showing three lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, and where we have intersection, the semi-transparent background becomes more opaque. The challenge is to get this result without the increase in alpha in the intersection areas.
the problem at hand: the overlapping parts appear darker because of the layered opacity

Temani Afif had already suggested using an SVG filter solution, and that was my first instinct too.

While the initial problem has a pretty simple solution, more complex variations lead me down a deep rabbit hole and I thought the journey was worth sharing in an article.

The initial problem and exact particular solution

We start with some middle-aligned text wrapped inside a p and a span. The span gets padding, border-radius, and a semi-transparent background.

p > span {
  padding: .25em;
  border-radius: 5px;
  background: rgb(0 0 0/ var(--a, .7));
  color: #fff;
  box-decoration-break: clone
}

We’re also setting box-decoration-break: clone so that each wrapped line gets its own padding and corner rounding (this is a very neat CSS feature that’s worth looking into if you’re not familiar with it).

The result of the above code looks as follows:

Screenshot showing four lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, and where we have intersection, the semi-transparent background becomes more opaque. This is basically the same as the problem illustrated by the challenge image.
what the above CSS gives us: the overlap problem

This is pretty much the same as the screenshot Lucas posted, so let’s see how we can fix it with an SVG filter!

The first step is to make the background of the span opaque by setting --a to 1. This gets rid of the overlap increasing alpha problem because there is no more transparency. To restore that transparency, we use an SVG filter. We’ll get to that in a moment, but for now, these are the styles we add:

/* same other styles as before */
p {
  --a: 1;
  filter: url(#alpha)
}

The SVG filter needs to live inside an svg element. Since this svg element only contains our filter and no actual SVG graphics to be displayed on the screen, it is functionally the same as a style element, so there’s no need for it to be visible/ take up space in the document flow.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='alpha'>
    <!-- filter content goes here -->
  </filter>
</svg>
svg[height='0'][aria-hidden='true'] { position: fixed }

The first primitive, feComponentTransfer, takes the SourceAlpha (basically, the filter input, with the RGB channels of all pixels zeroed, all pixels become black, but keep their alpha) as input (in) and scales it to the desired alpha, basically giving us the semitransparent version of the shape of the span background. This is because the input alpha is 1 within the span background area and 0 outside it. Multiplying the desired alpha with 1 leaves it unchanged, while multiplying it with 0… well, zeroes it.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='alpha'>
    <feComponentTransfer in='SourceAlpha' result='back'>
      <feFuncA type='linear' slope='.7'/>
    </feComponentTransfer>
  </filter>
</svg>

We’ve also named the result of this primitive back so we can reference it later in primitives not immediately folowing this particular feComponentTransfer one.

Screenshot showing the semitransparent background for the same lines of text as before, but without the actual text and without any increase in alpha in the intersection areas.
result of the first filter step: the semitransparent black background

Now we have the semi-transparent multi-line span background with no increase in alpha in the overlap areas. But we still need to get the text and add it on top of it.

Next, we have a feColorMatrix primitive that uses the green channel as an alpha mask (the second value on the last row of the matrix is the only non-zero one) and maxes out (sets to 100%) all RGB channels of the output (last column, first three rows), basically painting the output white with an alpha equal to the input green channel value. This means the result is full transparency where the input’s green channel is zero (everywhere outside the white text) and opaque white where it’s maxed out (just for the white text).

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha">
    <feComponentTransfer in="SourceAlpha" result="back">
      <feFuncA type="linear" slope=".7" />
    </feComponentTransfer>
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 1 
              0 0 0 0 1 
              0 1 0 0 0"
    />
  </filter>
</svg>

Note that by default, the inputs of any primitives other than the very first one in the filter get set to the result of the primitive right before, so for this feColorMatrix primitive we need to explicitly set the input in to SourceGraphic.

Also note that there’s a reason behind using the green channel to extract the text. This is because when using Chrome and a wide gamut display, we may hit a bug which causes feColorMatrix to find for example red in what’s 0% red, 100% green and 0% blue. And it’s not just that, but extracting the red channel out of 100% red, 0% green and 0% blue doesn’t give us 100% red, but a lower value.

To get an idea of just how bad the problem is, check out the comparison screenshot below – everything should have all channels either maxed out or zeroed (like on the left), there should be no in betweens (like on the right).

Comparative screenshots for various tests: extracting just the individual channels, their negations, unions, intersections, XORs, as well as extracting them as alpha masks. On the left, we have the extected result: extract 100% out of each channel that's maxed out, 0% out of those zeroed. On the right (wide gamut case), we however find red, green, blue where these channels have been zeroed.
expected vs. wide gamut problem (live test)

After a bunch of tests, it results the problem is less noticeable when using the green channel (compared to when using the blue or red channels), so we’re trying to limit this bug on the hardware where it’s possible to hit it.

We now have just the white text:

Screenshot showing just the white text for the same lines as before, no background at all.
result of the second filter step: just the white text

The final step is to place the semi-transparent black background underneath it (in2 specifies the bottom layer):

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha">
    <feComponentTransfer in="SourceAlpha" result="back">
      <feFuncA type="linear" slope=".7" />
    </feComponentTransfer>
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 1 
              0 0 0 0 1 
              0 1 0 0 0"
    />
    <feBlend in2="back" />
  </filter>
</svg>

I see feMerge often used for this, but here we only have two layers, so I find feBlend (with the default mode of normal which just places the top layer in over the bottom layer in2) a much simpler solution.

Note that we’re not specifying in explicitly because, by default, it’s the result of the previous primitive, the feColorMatrix. This is also why we didn’t bother with setting the result attribute like we did for the first primitive, the feComponentTransfer one because the output of this feColorMatrix primitive only gets fed automatically into the in input of the final primitive and nowhere else after that.

Cool, right?

Screenshot showing four lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, but now there is no more increase in alpha in the intersection areas.
the desired result (live demo)

Expanding the problem scope

I thought this was a neat trick worth sharing, so I posted about it on social media, which lead to an interesting conversation on Mastodon.

Patrick H. Lauke pointed me to a CodePen demo he had made a few years back, higlighting a related problem I wasn’t hitting with the quick demo I had shared: the background of the later lines covering up the text of the ones right before them.

My demo wasn’t hitting this problem because I had tried to stay reasonably close to the initial challenge screenshot, so I hadn’t used a big enough padding to run into it. But let’s say we increase the padding of the span from .25em to .5em (and also remove the filter to make the problem more obvious).

Screenshot showing four lines of text, middle aligned, each with its own fully opaque black background and padding. The padding on each line leads to its red background partly covering the white text of the line above.
the bigger padding problem

The simplest case: separate spans, opaque backgrounds, black/ white text

We first consider the case when we only have separate words wrapped in spans with opaque backgrounds and the text is either black or white (or at least very close). In this very simple case, a properly set mix-blend-mode on span elements (darken for black text, lighten for white) suffices, there’s no need for an SVG filter.

Screenshot showing a multi-line paragraph with isolated words highlighted by being wrapped in spans that get a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, white backdrop, pink highlight) and the dark theme case (right, white text, black backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the words they're meant to pop, overlapping some of the neighbouring ones, but they always show behind the text.
isolated spans on opaque contrasting background

Both darken and lighten value work on a per pixel, per channel basis. For each pixel of the input, they take either the minimum (darken) or the maximum (lighten) channel value betwen the two blended layers to produce the result.

Black always has all channels smaller or at most equal to those of anything else. So when we blend any background layer with black text using the darken blend mode, the result always shows the black text where there is overlap because the 0%-valued channels of the black text are always the result of the minimum computation.

White always has all channels bigger or at most equal to those of anything else. So when we blend any background layer with white text using the lighten blend mode, the result always shows the white text where there is overlap because the 100%-valued channels of the white text are always the result of the maximum computation.

Now this works fine as it is when we don’t have any backdrop behind or when the backdrop is either white for black text or black for white text. In other cases, for example if we have a busy image behind, things don’t look as good as the span elements also get blended with the image backdrop.

Screenshot showing the same multi-line paragraph with isolated words highlighted by being wrapped in spans that get a background contrasting with both the backdrop and the text in both the light and dark theme case. However, now the backdrop is a busy image and we can see how the highlights blend with it, instead of just being placed on top and covering it.
isolated spans on busy background problem

Luckily, the fix is straightforward: we just need to set isolation: isolate on the parent paragraph!

Screenshot showing the same multi-line paragraph with isolated words highlighted by being wrapped in spans that get a background contrasting with both the backdrop and the text in both the light and dark theme case. The backdrop is now a busy image, lighter or darker depending on the theme, but the highlights are simply on top, they don't get blended with it anymore.
isolated spans on busy background solution (live demo)

Slightly more complex: long wrapping span, opaque background, black/ white text

In this case, the mix-blend-mode solution isn’t enough anymore because the point of it was to blend the span background with the text of the parent paragraph that gets covered. But now it’s the span‘s own text that gets covered by the background of its next line.

Screenhot showing a multi-line paragraph with a long portion highlighted by being wrapped in a span that gets a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, light image backdrop, pink highlight) and the dark theme case (right, white text, dark image backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the portion they're meant to pop, overlapping some of the neighbouring words and lines, yet they always show behind the text of the paragraph around. Unfortunately, that's not enough, as they are painted above the text on the previous line that's also wrapped in the same span.
long wrapping span problem in spite of mix-blend-mode

To get around this, we wrap the entire span in another span and set the padding and background only on the outer span (p > span). This causes the black/white text of the inner span as well as that of the paragraph around the spans to get blended with the outer span background.

Screenhot showing a multi-line paragraph with a long portion highlighted by being wrapped in a span that gets a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, light image backdrop, pink highlight) and the dark theme case (right, white text, dark image backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the portion they're meant to pop, overlapping some of the neighbouring words and lines, yet they always show behind the text of the paragraph around and the text on the previous line that's also wrapped in the same span.
long wrapping span nesting solution (live demo)

If you’ve checked the above demo in Firefox, you may have noticed that it doesn’t work. This is due to bug 1951653.

In the particular case when the entire text in the paragraph is wrapped in a span, we can avoid the Firefox bug by setting the mix-blend-mode property only on the inner span (span span).

However, in the case above, where we also have paragraph text outside the outer span too, this unfortunately still leaves us with the problem of that text before the long span getting covered by the background of the next span line.

Screenhot showing a multi-line paragraph with a long portion highlighted by being wrapped in a span that gets a background contrasting with both the backdrop and the text. Both the light theme case (left, black text, light image backdrop, pink highlight) and the dark theme case (right, white text, dark image backdrop, crimson highlight) are included. The highlights expand out quite a bit outside the portion they're meant to pop, overlapping some of the neighbouring words and lines, yet they always show behind text on the previous line that's also wrapped in the same span. Unfortunately, not also below the text of the paragraph before this long rapping span.
Firefox workaround not good enough if there’s paragraph text before the long wrapping span

The most complex case: transparent background where neither the text nor the background are black/white

In this case, the blending solution isn’t enough anymore and we need an SVG filter one.

Going back to our original demo, we need to apply the solution from the previous case: wrap the span in another, set the padding and background only on the outer one (p > span), blend only the inner span element with the outer one to ensure our solution works cross-browser (since we have white text, we use the lighten mode) and prevent blending with anything outside the containing paragraph p by setting isolation: isolate on it.

p {
  color: #fff;
  isolation: isolate;
  filter: url(#alpha)
}

p > span {
  padding: .5em;
  border-radius: 5px;
  background: #000;
  box-decoration-break: clone;
	
  span { mix-blend-mode: lighten }
}
Screenshot showing four lines of text, middle aligned, each with its own semitransparent background and padding. The padding on each line leads to intersection, not just between padding areas on adjacent lines, but also between padding and text, but now there is no more increase in alpha in the padding intersection areas and all the background is always behind all the text.
the desired result in the bigger padding case (live demo)

But what we want here is to move away from black/ white text and background, so let’s see how to do that.

Set RGBA values in the SVG filter

If we wanted to have a background that’s not semi-transparent black, but a semi-transparent dark blue, let’s say rgb(25 25 112) (which can also be written as rgb(9.8% 9.8% 43.9%)), as well as gold-orange text, let’s say rgb(255 165 0) (which can also be written as rgb(100% 64.7% 0%)), then we use feColorMatrix as the first primitive as well and alter the final column values on the first three matrix rows for both the first matrix giving us the background and the second one giving us the text to use the decimal representation of the three percentage RGB values:

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha" color-interpolation-filters="sRGB">
    <feColorMatrix
      values="0 0 0 0  .098 
              0 0 0 0  .098 
              0 0 0 0  .439 
              0 0 0 .7 0"
      result="back"
    />
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 1 
              0 0 0 0 .647 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feBlend in2="back" />
  </filter>
</svg>

Other than the id, we’ve now also set another attribute on the filter element. We aren’t going into it because I don’t really understand much about it, but just know that this attribute with this value needs to be added on any SVG filter that messes with the RGB channels. Otherwise, the result won’t be consistent between browsers (the default is linearRGB in theory, but only the sRGB value seems to work in Safari) and it may not match expectations (the sRGB value is the one that gives us the result we want). Previously, having just white text on a black background, we didn’t really need it and it was safe to skip it, but now we have to include it.

Screenshot showing four lines of golden text, middle aligned, each with its own semitransparent dark blue background and padding. The padding on each line leads to intersection, not just between padding areas on adjacent lines, but also between padding and text, but there is no increase in alpha in the padding intersection areas and all the background is always behind all the text.
golden text on dark blue background using the method of setting the RGB values in the SVG filter (live demo)

The problem with this solution is that it involves hardcoding the RGBA values for both the span background and text in the SVG filter, meaning we can’t control them from the CSS.

Let’s try another approach!

Set RGBA values upstream of the SVG filter

First, we set them as custom properties upstream of the svg:

body {
  --a: .5;
  --back-c: rgb(25 25 112/ var(--a));
  --text-c: rgb(255 165 0)
}

Then we modify the filter a bit. We use SourceAlpha to give us the background area, though we still extract the text area via a feColorMatrix primitive and save it as text, but this time we don’t care about the RGB values, we won’t use them anyway. We also flood the entire filter area with --back-c and --text-c (using feFlood), but then, out of the entire area, we only keep what’s at the intersection (operator='in' of feComposite) with the SourceAlpha and text areas respectively. Finally, we stack these intersections (via feBlend), with the text on top.

<svg width="0" height="0" aria-hidden="true">
  <filter id="alpha" color-interpolation-filters="sRGB">
    <feFlood flood-color="var(--back-c)" />
    <feComposite in2="SourceAlpha" operator="in" result="back" />
    <feColorMatrix
      in="SourceGraphic"
      values="0 0 0 0 0 
              0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
      result="text"
    />
    <feFlood flood-color="var(--text-c)" />
    <feComposite in2="text" operator="in" />
    <feBlend in2="back" />
  </filter>
</svg>

This allows us to control both the text and background from the CSS.

However, the values of --back-c and --text-c are those of the feFlood primitive, not those on the element the filter applies to. So for any different text or background, we need to have a different filter.

If that’s difficult to grasp, let’s say we want two different options, the same golden-orange text on a dark blue background and also dark blue text on a pink background.

body {
  --a: .7;
	
  --back-c-1: rgb(25 25 112/ var(--a));
  --text-c-1: rgb(255 165 0);
	
  --back-c-2: rgb(255 105 180/ var(--a));
  --text-c-2: rgb(25 25 112);
	
  --back-c: var(--back-c-1);
  --text-c: var(--text-c-1)
}

Now we can change --back-c and --text-c on the second paragraph:

p:nth-child(2) {
  --back-c: var(--back-c-2);
  --text-c: var(--text-c-2)
}

But changing these variables on the second paragraph doesn’t do anything for the result of the SVG filter applied to it because the values for --back-c and --text-c that get used by the filter are always those set upstream from it on the body.

the problem seen in DevTools

Unfortunately, this is just how things are for SVG filters, even though CSS ones don’t have this limitation, like the comparison below shows.

Screenshot illustrating the above. `--c` is set to `orangered` on the body and this is the value used for the drop shadow created by the SVG filter, regardless of what value `--c` has on the element the SVG filter is applied on. By contrast, when using a CSS drop shadow filter, the value of `--c` is the one set on the element the filter is applied on.
CSS vs. SVG drop-shadow filter using a variable for flood-color (live demo)

Set RGB values in the CSS, fix alpha in the SVG filter

Amelia Bellamy-Royds suggested a feComponentTransfer approach that allows setting the palette from the CSS and then using the SVG filter only to take care of the increase in alpha where there is overlap.

What Amelia’s filter does is use feComponentTransfer to preserve the alpha of everything that’s fully transparent (the area outside the span) or fully opaque (the text), but map a bunch of alpha values in between to the desired background alpha a. This should also catch and map the background overlap alpha (which is a + a - a*a = 2*a - a*a – for more details, see this Adventures in CSS Semi-Transparency Land article) to a.

This is a very smart solution and it seems to work really well for this particular background and text case as well as for similar cases. But there are still issues, points where it breaks.

First off, if we increase the alpha to something like .75, we start seeing an overlap.

Screenshot showing four lines of golden text, middle aligned, each with its own semitransparent dark blue background and padding. The padding on each line leads to intersection, and where we have intersection, the semi-transparent background becomes more opaque.
overlap problem becoming visible when alpha is bumped up to .75

My first instinct was to do what Amelia also suggests doing in the comments to her version – increase the number of intervals as the alpha gets closer to the ends of the [0, 1] interval.

Since I’m using Pug to generate the markup anyway, I figured this would be a good way to first measure how large the base intervals would need to be – and by that I mean the minimum between the distance between the ends of the [0, 1] interval and the desired alpha as well as the overlap alpha.

We’re excluding 2*a - a*a and 1 - a from the minimum computation since a is subunitary, so a is always bigger than a*a, which results in a being always smaller than 2*a - a*a = a*(2 - a), which also results in 1 + a*a - 2*a being smaller than 1 - a.

Then we get how many such base intervals u we could fit between 0 and 1, round up this number (n) and then generate the list of alpha values (for tableValues) which remains 0 and 1 at the ends, but is set to a everywhere in between.

- let u = Math.min(a, 1 + a*a - 2*a);
- let n = Math.ceil(1/u);
- let v = new Array(n + 1).fill(0).map((_, i) => i*(n - i) ? a : i/n)

feFuncA(type='table' tableValues=v.join(' '))

This does indeed fix the background overlap problem for any alpha, though it still means we need different filters for different alphas. Here is what gets generated for a few different alpha values:

<!-- a = .8 -->
<feFuncA type='table' 
         tableValues='0 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 .8 1'/>

<!-- a = .75 -->
<feFuncA type='table' tableValues='0 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 .75 1'/>

<!-- a = .65 -->
<feFuncA type='table' tableValues='0 .65 .65 .65 .65 .65 .65 .65 .65 1'/>

<!-- a = .5 -->
<feFuncA type='table' tableValues='0 .5 .5 .5 1'/>

<!-- a = .35 -->
<feFuncA type='table' tableValues='0 .35 .35 1'/>

<!-- a = .2 -->
<feFuncA type='table' tableValues='0 .2 .2 .2 .2 1'/>

<!-- a = .1 -->
<feFuncA type='table' tableValues='0 .1 .1 .1 .1 .1 .1 .1 .1 .1 1'/>

<!-- a = .05 -->
<feFuncA type='table' 
         tableValues='0 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 .05 1'/>

We also have another bigger problem: due to font anti-aliasing, the feComponentTransfer messes up the text for lower value alphas and the lower the value, the worse the problem looks.

Font anti-aliasing makes the edge pixels of text semi-transparent in order to avoid a jagged, pixelated, ugly, even broken look. For comparison, below is the same text without vs. with anti-aliasing, at normal size and scaled up 12 times:

The text "Pixels" without (left) vs. with anti-aliasing (right), in normal size (top) and scaled up 12x times (bottom). The normal size text looks a bit rough in the no anti-aliasing version at normal size, whereas its anti-aliased version looks smooth. On zoom, we can see this is due to the no-anti-aliasing version having only fully opaque and fully transparent pixels, whereas the other version has its edges smoothened by semi-transparent pixels with various alpha levels.
without vs. with anti-aliasing

Those semi-transparent font edge pixels placed on top of the semi-transparent background also give us semi-transparent pixels. At the same time, our filter maps the alpha of more and more of the semi-transparent pixels of the input to the desired background alpha a as this a nears the ends of the [0, 1] interval. As a nears 0, then almost all semi-transparent edge pixels get this very low a alpha, making them much more transparent than they should be and causing an eroded look for our text.

illustrating the problem caused by anti-aliasing (live demo)

I guess a simple fix for that would be to only map to the desired alpha a the smallest number of alpha points possible and let all others keep their initial alpha. This would mean that the first alpha point we map to the desired alpha a is equal to it or the nearest smaller than it, while the last one is equal to the overlap alpha 2*a - a*a or the nearest bigger than it.

For example, if the desired alpha a is .2, then the overlap alpha is .2 + .2 - .2*.2 = .36. The base interval u is .2n is 1/.2 = 5, so we generate n + 1 = 6 alpha points:

0 .2 .4 .6 .8 1

If before we mapped all those between 0 and 1 to the desired alpha .2, now we only map to the desired alpha a, those loosely matching the [.2, .36] interval – that is, .2 and .4:

0 .2 .2 .6 .8 1

In general, that means our values array would become:

- let v = new Array(n + 1).fill(0);
- v = v.map((_, i) => (i*(n - i) && (i + 1)/n > a && (i - 1)/n < a*(2 - a)) ? a : i/n);

Probably ensuring the values outside the interval mapped to a are evenly distributed would be the more correct solution, but this simpler trick also seems to work really well when it comes to fixing the text erosion problem.

testing a basic fix for the antialiasing problem (live demo)

But you may have noticed there’s still a problem and this is not an SVG filter one, it comes from the CSS.

To make it more obvious, let’s put result right next to what we got via the earlier method of seting the RGBA values from the SVG filter – can you see it?

setting RGBA values in SVG filter method vs. RGB in CSS plus alpha fixing via SVG filter method (live demo)

If you can’t spot it in the recording above, how about when we have a diagonal middle split in between the result we get when we bake into the filter all RGBA values and the result we get with this alpha fix method via feComponentTransfer?

Split comparison screenshot. The paragraph box is split into two triangles, lightly separated by a gap along the secondary diagonal. In the top left triangle, we have the result obtained using the method of hardcoding the RGBA values for both the text and the background into the SVG filter. In the bottom right one, we have the result obtained using the method of setting the RGBA values in the CSS and then using an SVG filter to fix the overlap alpha to be the desired background alpha as well. Both use a background alpha of .85 and in his case, it looks like the text using the second method is a bit more faded.
split comparison

It’s pretty subtle here, but if you think it looks like this latest method is making the text a bit more faded, particularly at higher alpha values, you’re right.

This is because the blending fix for the background overlapping text problem results in the text color not being preserved. This was precisely why we switched from a blending-only solution to an SVG filter one in the case when the text isn’t black or white (or close enough and the particular choice of text and background preserves the text post-blending exactly as it was set).

A lot of text and background combinations don’t make this very obvious because, in order to have a good contrast ratio, we often need either the text or the background behind it to be very dark or very bright – which means there’s a chance all three RGB channels of the text are either below or above the corresponding RGB channels of the background, or even if one of the channels is deviating on the other side, it’s not deviating enough to make a noticeable difference. But sometimes we can still see there’s a problem, as illustrated by the interactive demo below, which allows changing the palette.

All of these palettes were chosen to have a good contrast ratio. Even so, there is some degree of text fading for all of them. And while it’s not easy to spot that for the first five, it’s way more noticeable for the second to last one and almost impossible to miss for the final one.

Let’s take the second to last one, which uses a lighter blue than our initial palette, so it has a somewhat lower contrast making the problem more obvious. The higher the alpha gets, what should be golden text on a semitransparent deep blue background looks more pink-ish. This is due to the text being rgb(100% 74.51% 4.31%) and the background being rgb(22.75% 21.18% 100%) (we leave out the transparency for now and assume the alpha is 1). Blending these using the lighten blend mode means taking the maximum value out of the two for each channel – that is, 100% (max(100%, 22.75%)) for the red channel, 74.51% (max(74.51%, 21.18%)) for the green one and 100% (max(4.31%, 100%)) for the blue one. That means our text is rgb(100% 74.51% 100%), a light pink, which is different from the color value of rgb(100% 74.51% 4.31%) (golden) we’ve set.

particular case of the second to last palette

The final text and background combination makes the problem even more clear. The higher the alpha gets, what should be lime text on a semitransparent blue background looks more like aqua text. This is due to the text being rgb(0% 100% 0%) and the background being rgb(0% 0% 100%) (again, we leave out the transparency for now and assume the alpha is 1). Blending these using the lighten blend mode means taking the maximum value out of the two for each channel – that is, 0% (max(0%, 0%)) for the red channel, 100% (max(100%, 0%)) for the green one and 100% (max(0%, 100%)) for the blue one. That means our text is rgb(0% 100% 100%), so aqua, which is different from the color value of rgb(0% 100% 0%) (lime) we’ve set.

particular case of the final palette

So what now? Well, the one solution I’ve been able to find is to pass in the text and background shapes separate from the RGBA values used for them. I’ve tried approaching this in multiple ways and ended up hitting bugs in all browsers. Tiling bugs in Safari and Chrome, a weird Windows-specific bug in Firefox, the same wide gamut bug mentioned before in Chrome… bugs everywhere.

So now we’re not going through all of my failed experiments, of which there were many, we’re just looking at the one solution I’ve managed to get working reasonably well across various browser, OS and hardware combinations.

Set shapes and RGBA values in the CSS, pass them to the SVG filter via different channels/ alpha points

The shape of the span background and that of the text get passed to the SVG filter using the 1 alpha point. That means we have white text on black background, all opaque, so we can extract it in the SVG by mapping all alpha points except 1 to 0.

We pass the text and background RGB values using the .75 and .25 alpha points – this allows us to extract them in the SVG filter by mapping their corresponding alpha points to 1, while all other alpha points are 0.

Finally, we pass the alpha value to the SVG via the green channel, using the .5 alpha point. By mapping the .5 alpha point to 1, while all other alpha points get mapped to 0, we can extract in the SVG filter the desired background alpha value via the green channel value.

This means we have five alpha points (0.25.5.75 and 1), so we’re going to need to use five values for the tableValues attribute of feFuncA, all of them zeroed, except the one corresponding to the point we’re interested in and which we map to 1.

In order to do this, we first add an absolutely positioned, non-clickable pseudo on the p element. This pseudo has a border and two shadows (an outer one and an inset one) and is offset outwards (using a negative inset) to compensate for both the inset shadow and the border, so that there is no visible part of this pseudo intersecting the span background shape.

p {
  --a: 0.7;
  --text-c: rgb(255 165 0);
  --back-c: rgb(25 25 112);
  position: relative;

  &::after {
    position: absolute;
    inset: -2em;
    border: solid 1em rgb(0% calc(var(--a) * 100%) 0%/ 0.5);
    box-shadow: inset 0 0 0 1em rgba(from var(--text-c) r g b/ 0.75),
      0 0 0 1em rgba(from var(--back-c) r g b/ 0.25);
    pointer-events: none;
    content: "";
  }
}

The first shadow is an inset one using the desired text RGB value and a .75 alpha, which allows us to pass the RGB value to the SVG filter via the .75 alpha point. The second shadow is an outer one using the desired background RGB value and a .25 alpha, which allows us to pass the RGB value to the SVG filter via the .25 alpha point.

The border-color uses the desired span background alpha value on the green channel (we’re using the green channel due to the same Chrome wide gamut bug mentioned earlier in this article) and has a .5 alpha. This allows us to pass to the SVG filter the value of the desired span background alpha as the green channel value using the .5 alpha point.

The negative inset (-2em) is set to compensate for both the inset shadow (with a 1em spread) and for the border (with a 1em width) because it’s very important that none of the visible parts of the pseudo (the border and the box-shadow using the .25.5 and .75 alpha points) intersect the shape of the span background (using the 1 alpha point).

The pointer-events: none property is there in order to avoid any interference with the span text selection. We could have also used z-index: -1, since there is no intersection between the visible parts of the pseudo and the span background shape. Both of them do the job and in this case, it really doesn’t matter which we choose to use.

What we have so far definitely doesn’t look great, but… we’re getting there!

Screenshot showing the result of the above CSS. We have four lines of white text, middle aligned, each with its own black background and padding. Although the background of each line overlaps the text of the adjacent ones, the text is shown everywhere on top. Around these lines of text, without touhing them, we have three nested frames. The innermost one has an alpha of .75 and the RGB value we want for the text in the final version. The middle one has an alpha of .5 and has the red and blue channels zeroed, while the green one has the value of the desired background alpha. The outer one has an alpha of .25 and the RGB value we want for the background.
before applying any filter

Moving on to the filter, we start in a similar manner as before, by getting the opaque part. To do so, we preserve just just the fifth alpha point (1), while mapping all others to 0. Everything that intially has an alpha of 0 (transparent part inside the frames around the span shape), .25 (outermost dark blue frame), .5 (middle green frame) or .75 (innermost golden frame) becomes transparent.

<svg width='0' height='0' aria-hidden='true'>
  <filter id='go' color-interpolation-filters='sRGB'>
    <feComponentTransfer result='opaque'>
      <feFuncA type='table' tableValues='0 0 0 0 1'/>
    </feComponentTransfer>
  </filter>
</svg>

We’ve saved this result as opaque for when we need to use it later.

Screenshot showing what we get after the first  primitive: just the four lines of white text on black packground.
the opaque result

Next, from the initial filter input, we extract the background RGB area by mapping the second (.25) alpha point to 1, while mapping all others to 0. Note that we don’t want the input of the second primitive to be the result of the first one, but the filter input, so we explicitly specify in as SourceGraphic.

<svg width="0" height="0" aria-hidden="true">
  <filter id="go" color-interpolation-filters="sRGB">
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
  </filter>
</svg>

In theory, this second feComponentTransfer extracts second just the background RGB area (pseudo outer shadow area, using the second alpha point, .25). In practice, can you see what else it has picked up?

Screenshot showing what we get after the second  primitive: just the outermost frame, the one holding the background RGB, but now with its alpha set to , fully opaque.
the outer frame using the background RGB

If you cannot pick it up (it’s not easy), let’s remove the image backdrop and circle the problem areas:

Screenshot showing the same outermost frame with the background RGB, now on a white background that allows us to see that in the middle of the rectangle this frame is placed around, there are a few stray black pixels.
highlighting the problem areas

Those black pixels it picks up are again due to anti-aliasing. At the rounded corners of the span background lines, we have semitransparent pixels in order for these corners to look smooth, not jagged. But then our second feComponentTransfer maps the pixels in the [0, .25] interval to [0, 1] and the pixels in the [.25, .5] interval to [1, 0]. And this doesn’t catch just the pixels of the pseudo’s outer shadow using the .25 alpha point, but also the pixels in the [0, .5] interval at those rounded corners of those span background lines, which get a non-zero alpha too.

Now in our particular case where we have a black span background, we can safely just ignore those pixels when moving on to the next step. But if we were to have a red background there, things would be very different and those pixels could cause a lot of trouble.

That’s because at the next step we expand the background RGB frame we got to cover the entire filter area and we do that with a feMorphology primitive using the dilate operation. What this does is the following: for every channel of every pixel, it takes the maximum of all the values of that channel for the pixels lying within the specified radius (from the current pixel) along both the x and the y axes in both the negative and positive direction.

Below, you can see how this works for a channel whose values are either maxed out (1) or zeroed (0). For every pixel of the input (green outline around the current one), the corresponding output value for the same channel is the maximum of all the values for that channel in the vicinity of the current pixel (within the red square).

how dilation works in the general case

For our purpose, we first care about the alpha channel, since this turns opaque all transparent pixels that are within the specified radius from any opaque one along both axes in both directions, effectively dilating our frame to fill the area inside it.

But the maximum computation happens for the RGB channels too. Black has zero for all RGB channels, so those stray pixels don’t affect the result of the maximum computation since every single one of the RGB channels of the frame is above zero, which makes them be the result of the maximum for every single one of the RGB channels.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />
  </filter>
</svg>

Note that the filter now has primitiveUnits set to objectBoundingBox so values for attributes such as the radius attribute of feMorphology are not pixel values anymore, but relative to the filter input box size. This is because the size of our filter area is given by its input, whose exact pixel size is determined by the text content which we have no way of knowing. So we switch to relative units.

dilating the frame to fill the filter area (black span background)

There are two things to keep in mind here.

One, I’m not exactly happy to have to use such a relatively large dilation value, as it can negatively impact performance (at least from the tests on my laptop, the performance hit is obvious in both Firefox and Epiphany for the final demo). But unfortunately, my initial idea of extracting small squares in the top left corner and then tiling them ran into at least one different bug in every browser on at least one OS, so I guess this dilation was the only option left.

Two, if we had a red (rgb(100% 0% 0%)) instead of a black (rgb(0% 0% 0%)) background, then the maxed up red channel would cause trouble since 100% is a bigger value than the 9.8% of the frame (desired RGB being rgb(9.8% 9.8% 43.9%)), so then we’d end up with those pesky corner pixels bloating up and turning the intersection with the dilated frame purple, a mix (rgb(max(100%, 9.8%) max(0%, 9.8%) max(0%, 43.9%))) between the red channel of the initial red span background and the green and blue channels of the frame (which has the desired RGB value for the background and whose red channel we’d lose this way).

dilating the frame to fill the filter area (red span background)

In such a case where a red input area would “contaminate” our desired background RGB, we’d first need to apply a small erosion to get rid of those pesky corner pixels before we apply the dilation. Erosion works in a similar manner to dilation, except we take the minimum channel value of all pixels within the set radius along both axes in both directions.

how erosion works in the general case

In our case, we care about the alpha channel erosions, all the transparent pixels around zeroing the alpha of those few ones we didn’t really mean to pick up.

<feMorphology radius='.01'/>

Note that erode is the default operator, so we don’t need to explicitly set it.

Back to our case, after dilating the frame to fill the entire filter area with the desired background RGB and saving this result as back-rgb, we extract (again, out of the initial filter input) the desired alpha as the green channel value of the pseudo border with a .5 alpha. This means another feComponentTransfer, this time one mapping all alpha points to 0, except for the third one (.5), which gets mapped to 1 (though in this one case the exact alpha it gets mapped to doesn’t really matter as long as its non-zero).

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
  </filter>
</svg>

This gives us a green frame (red and blue channels zeroed, green channel set to the value of the desired alpha for the background of the span lines):

Screenshot showing what we get after the third  primitive: just the middle frame, the one holding the background alpha on the green channel, but now with its alpha set to , fully opaque.
the middle frame using the desired alpha on the green channel

Now you can probably guess what follows: we dilate this green frame to cover the entire filter area. Again, we have those stray black pixels, but since they’re black, their channel values just get discarded when we perform the dilation, so we don’t need that erosion step in between.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
  </filter>
</svg>

We don’t save the result of this primitive this time, but we’ll get to that in a moment. This is what we have now – not too exciting yet, though things are about to change.

Screenshot showing the middle frame from before (holding the background alpha on the green channel) dilated in all directions to the point it has filled the entire filter area.
middle frame dilated to fill entire filter area

Next, we use feColorMatrix to give this layer covering the entire filter area an alpha equal to that of its green channel. This is why we don’t save the result of the second feMorphology – because we only feed it into the input of the very next primitive, feColorMatrix and then we don’t need it anywhere after that. We don’t care about the RGB values of the result, only about the alpha, so we just zero them all.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
  </filter>
</svg>

Basically, what this feColorMatrix does is set the output alpha channel to be equal to the input green channel (well, to 1 multiplied with the input green channel), regardless of the values of the other input channels (red, blue, alpha). This way, we recover the alpha channel from the green one.

Screenshot showing the fill from the previous step, now with the green channel value transferred onto the alpha channel, giving us a semi-transparent fill, of the alpha we want for the  background.
alpha value finally on the alpha channel

Next step is to intersect the previously saved back-rgb result with this one, so we keep the RGB channels of that layer and the alpha channel of this one.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
  </filter>
</svg>

What happens here is the alphas of the two input layers (1 for back-rgb and the desired span background alpha for the other) are multiplied to give us the output alpha. At the same time, we only keep the RGB values of the top one (back-rgb) for the output.

Screenshot showing us the result of compositing the full filter area fill background RGB and background alpha layers. This has the desired background RGB and the desired background alpha.
the desired semi-transparent background, filling the filter area

We now have the entire filter area covered by a layer with the desired RGBA for the span background lines, so the next step is to restrict it to the area of those span lines, opaque. That is, only keep it at the intersection with that area and save the result as back.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />
  </filter>
</svg>

It finally looks like we’re getting somewhere!

Screenshot showing us the result of the latest compositing step: the background RGBA layer at the intersection with the initial opaque shape of the  background area.
the semi-transparent dark blue background of the span

Next, we can move on to the text!

We start by extracting the text RGB area by mapping the fourth (.75) alpha point to 1, while mapping all others to 0. Again, we explicitly specify in as SourceGraphic.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
  </filter>
</svg>

This gives us yet another frame, this time one in the gold we want for the text.

What we get after the fourth  primitive: just the innermost frame, the one holding the text RGB, but now with its alpha set to , fully opaque.
the inner frame using the text RGB

Just like we did for the other frames, we dilate this one too in order to make it fill the entire filter area and save this result as text-rgb.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />
  </filter>
</svg>
Screenshot showing the inner frame from before (holding the text RGB value) dilated in all directions to the point it has filled the entire filter area.
inner frame dilated to fill entire filter area

Then we extract the text shape from the opaque layer, just like we did before, using the green channel like an alpha mask.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
  </filter>
</svg>

My expectation was that this would give us just the text shape like below, which is what happens in Chrome.

Screenshot showing the Chrome result when extracting the green channel as an alpha mask from the initial opaque portion of the input, the one having white text (all three RGB channels maxed out) on black background (all three RGB channels zeroed). This is just the actal text area in this case, the green channel of the frames appears to have been thrown out when thir alphas got zeroed at the first step which gave us just the fully opaque area.
the text shape extracted using the green channel (Chrome)

However, Firefox does something interesting here and thinking it through, I’m not entirely sure it’s wrong.

Screenshot showing the Chrome result when extracting the green channel as an alpha mask from the initial opaque portion of the input, the one having white text (all three RGB channels maxed out) on black background (all three RGB channels zeroed). This is not just the white text in this case, but also the frames as the filter input had non-zero green channel values there and unlike Chrome, Firefox doesn't seem to have discarded their RGB values when zeroing the frame alphas at the first step extracting just the fully opaque portion.
Firefox extracting more than just the text shapes

What seems to happen is that Chrome forgets all about the RGB values of the semi-transparent areas of the pseudo and just zeroes them when zeroing their alphas in the first feComponentTransfer primitive to extract the opaque part (the span with white text on solid black background). Then when using the green channel as an alpha mask on the opaque part, all that’s not transparent is the white text, where the green channel is maxed out.

However, Firefox doesn’t seem to throw away the RGB values of those semi-transparent frames created by the border and box-shadow on the pseudo, even if it also zeroes their alphas via the first primitive as well. So even though the opaque result looks the same in both browsers, it’s not really the same. Then when we get to this latest feColorMatrix step, Firefox finds green in those now fully transparent frames because even though their alpha got zeroed to get the opaque result, their RGB values got preserved.

Whichever browser is right, there’s a very simple way to get the result we want cross-browser: intersect what we have now with the opaque result. It doesn’t even matter the RGB values of which layer we choose to preserve as a result of this intersection because we won’t be using them anyway.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
  </filter>
</svg>

The next step is to keep the text-rgb layer only at the intersection with the text we just got.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
    <feComposite in="text-rgb" operator="in" />
  </filter>
</svg>
Screenshot showing the golden fll layer kept just at the intersection with the text shape.
the golden text of the span

Finally, we place this on top of the back layer with a feBlend, just like we did before.

<svg width="0" height="0" aria-hidden="true">
  <filter
    id="go"
    color-interpolation-filters="sRGB"
    primitiveUnits="objectBoundingBox"
  >
    <feComponentTransfer result="opaque">
      <feFuncA type="table" tableValues="0 0 0 0 1" />
    </feComponentTransfer>

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 1 0 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="back-rgb" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 1 0 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" />
    <feColorMatrix
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="back-rgb" operator="in" />
    <feComposite in2="opaque" operator="in" result="back" />

    <feComponentTransfer in="SourceGraphic">
      <feFuncA type="table" tableValues="0 0 0 1 0" />
    </feComponentTransfer>
    <feMorphology operator="dilate" radius=".5" result="text-rgb" />

    <feColorMatrix
      in="opaque"
      values="0 0 0 0 0 
              0 0 0 0 0 
              0 0 0 0 0 
              0 1 0 0 0"
    />
    <feComposite in="opaque" operator="in" />
    <feComposite in="text-rgb" operator="in" />
    <feBlend in2="back" />
  </filter>
</svg>

This is our final result!

This allows us to have full control from the CSS over the text and background RGB, as well as over the background alpha, without needing to hardcode any of them in the SVG filter, which means we don’t need a different SVG filter if we want to set a different value for any of them on one o the elements the filter is applied to.

Now you may be thinking… well, this looks ugly with those semi-transparent frames before the filter is applied, so what if the filter fails? Well, the fix is really simple. clip-path gets applied after filter, so we can clip out those frames. They still get used for the filter if the filter is applied, but if it fails, we are still left with the very reasonable choice of white text on black background.

The following demo has different text and background combinations for each paragraph. All paragraphs use the exact same filter (the one above), they just have different values for --text-c--back-c and --a.

]]>
https://frontendmasters.com/blog/overlapping-inline-backgrounds/feed/ 4 5330
Pure CSS Halftone Effect in 3 Declarations https://frontendmasters.com/blog/pure-css-halftone-effect-in-3-declarations/ https://frontendmasters.com/blog/pure-css-halftone-effect-in-3-declarations/#comments Tue, 03 Dec 2024 20:50:56 +0000 https://frontendmasters.com/blog/?p=4594 About half a decade ago, I got an idea about how to create a halftone effect with pure CSS. My original idea (which Michelle Barker wrote about a couple of years ago) was a bit inefficient, but in the years that followed, I’ve managed to polish it and reduce it to a single <div>, no pseudos and just three CSS properties.

What’s a halftone effect?

If you don’t know what a halftone effect is, a very basic pattern looks like this:

The simplest possible halftone pattern

This is what we’ll be creating with a single <div> (no pseudo-elements) and only three CSS declarations. Afterwards, we’ll go through a bunch of variations and see some cooler-looking demos.

The 3 CSS Declarations

The first declaration is a background and it consists of two layers. One is the pattern – the dots in our most basic case. The other is the map – this decides where the dots are bigger and where they are smaller. In the most simple case, it’s a linear gradient. So what we have so far in terms of code looks like this:

background: 
  radial-gradient(closest-side, #000, #fff) 0/ 1em 1em space, 
  linear-gradient(90deg, #000, #fff);

We’ve made sure we have an integer number of dots along both axes by using the space value for background-repeat.

Taken separately, the two layers look like this:

the pattern and the map

Before we move any further, let’s take a closer look at these gradients. Each of the two layers goes from black, which can also be written as rgb(0%, 0%, 0%) or hsl(0, 0%, 0%) to white, which can also be written as rgb(100%, 100%, 100%) or hsl(0, 0%, 100%).

Dead in the middle we have grey, which is rgb(50%, 50%, 50%) or hsl(0, 0%, 50%). This is the 50% lightness grey or, in short, as we’ll be calling it from now on, the 50% grey.

Note that in the case of any grey, wherever it may be situated in between black and white, the saturation (the ‘S’ in HSL) is always 0%, while the hue (the ‘H’ in HSL) is irrelevant, so we just use 0. The only value that changes is the lightness (the ‘L’ in HSL), which goes from 0% for black to 100% for white.

Basically, going from 0% to 100% along the gradient line means going from 0% to 100% along the lightness axis of the HSL bicone.

HSL bicone slice showing the lightness axis (live demo)

So in general, any p% grey can be written as rgb(p%, p%, p%) or hsl(0, 0%, p%).

This can be seen in the interactive demo below where you can drag the bar along the entire lightness range.

Going back to our background with the pattern dots layer on top of the linear-gradient() map layer, we cannot see the map layer because it’s fully covered by the pattern layer. So the next step is to blend these two background layers using the multiply blend mode.

This means the second declaration is:

background-blend-mode: multiply

This works on a per pixel, per channel basis. We consider each layer to be a grid of pixels, we take every pair of corresponding pixels from the two layers and, for each of the three RGB channels, we multiply the corresponding channel values.

blending two layers at a pixel level

So for each pair of pixels, the result of this blending operation is an RGB value where each channel value is the result of multiplying the corresponding channel values from the two layers.

R = R₀·R₁
G = G₀·G₁
B = B₀·B₁

Note that what we’re multiplying is the decimal representation of percentage RGB values – that is, numbers in the [0, 1] interval. And when multiplying values in this interval, the result is always smaller or equal to the smallest of the two values multiplied.

In our case, both gradients go from black to white, all we have in between are greys, which have all three RGB channels equal. So if at some point, both pixels in the pair of corresponding ones from the two layers have rgb(50%, 50%, 50%), then the result of the multiply blend mode is .25 = .5·.5 for each channel.

We can see that the result of the multiply blend mode is always at least as dark as the darker of the two pixels whose RGB values we multiply. This is because the two RGB values are in the [0, 1] interval and, as mentioned before, multiplying such values always gives us a result that’s at most as big as the smallest of the two numbers multiplied. The smaller the channel values are, the darker the grey they represent is.

After blending our pattern and map layers, we can see how overall, the pattern dots are now darker on the left where the map is closer to black.

our two gradient layers, blended

Below, you can see two scaled up dots from different points along the gradient line of the map. The second dot is further to the right (lighter) than the first one. The dark red circles mark the 50% grey limit for each.

darker dot vs. lighter dot

For the darker dot, the 50% grey limit is a bigger circle than in the case of the lighter dot. Inside each dark red circle, we have greys darker than a 50% one. Outside, we have greys lighter than a 50% one. Keep this in mind for later.

The third and final declaration is a filter using a large contrast() value.

For those not familiar with how contrast() works, it does one of two things, depending on whether its argument is subunitary or not.

If its argument is subunitary, then it pushes every channel value towards .5 , the middle of the [0, 1] interval. A value of 1 means no change, while a value of 0 means the channel has been pushed all the way to .5.

This means that contrast(0) always gives us a 50% grey, regardless of the filter input.

You can see this in the interactive demo below – regardless of whether we apply our filter on a plain solid background box, opaque or semitransparent, a gradient or an image one, dragging the contrast down to 0 always turns it into a 50% grey with the same alpha as the input.

Note that contrast(100%) is the same as contrast(1)contrast(50%) is the same as contrast(.5) and so on.

If the argument of the contrast() function is greater than 1 however, then each channel value gets pushed towards either 0 or 1, whichever is closer. A contrast large enough can push the channel values all the way to 0 or 1.

If we have a large enough contrast, all channel values are either zeroed (0%) or maxed out (100%) meaning we can only get one of eight possible results.

8 possible RGB values where all channels are either zeroed or maxed out

Coming back to our halftone pattern, we use:

filter: contrast(16)

Here, all greys darker than a 50% one (grey or rgb(50%, 50%, 50%) or hsl(0, 0%, 50%)) get pushed to black and all the others to white.

Now remember how the 50% grey limit was a bigger circle if the dot was darker? That’s our limit for the contrast.

Inside that circle, we have greys darker than a 50% one, so they get pushed to black by large contrast vales. Outside it, the greys are lighter than a 50% one, so they get pushed to white by large contrast values.

Since the darker the dot, the bigger the 50% limit circle, this means the halftone dots in the darker area of the map are bigger.

So here’s the result we get after the third and final declaration:

the result so far

We’re starting to get somewhere, but what we have so far is not ideal. And it makes sense we aren’t there yet.

Since the left half of the map is darker than a 50% grey (the RGB channel values are below 50% or .5 in decimal representation of the percentage), blending any other layer with it using the multiply blend mode gives us a result that’s at least as dark.

This means the result of blending across the entire left half is a grey darker than a 50% one, so that large value contrast pushes everything in the left half to black.

The fix for this is pretty straightforward: we don’t make our gradients go all the way from black to white, but rather from mid greys to white. Furthermore, for best results, the map at its darkest should be a little bit brighter than a 50% grey, while the pattern can be a bit darker.

background: 
  radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space, 
  linear-gradient(90deg, #888, #fff);

Much better!

Now one thing to note here is that the contrast value needs to be enough to compensate for the blur radius of our dots. So if we increase the pattern size (the background-size for the pattern layer), then we also need to increase the contrast value accordingly.

Let’s say we increase the background-size from 1em to 9em.

bigger dots, blurry edges

The dot edges are now blurry, so we also increase the contrast value from 16 to let’s say 80.

increased contrast, jagged edges

Unfortunately, this results in ugly edges.

A fix for this would be to then chain a slight blur and a contrast that’s enough to offset it. Generally, a contrast value that’s 2-3 times the blur value in pixels works pretty well.

filter: contrast(80) blur(2px) contrast(5)
tiny blur + contrast smoothing fix

An even better fix would involve using a custom SVG filter, but SVG filters are outside the scope of this article, so we’re not going there.

Variations

Now that we’ve gone through the basics, we can start making things more interesting in order to get a lot of cool results by varying at least one of the pattern or map layers.

background: 
  var(--pattern, radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space)),
  var(--map, linear-gradient(90deg, #888, #fff));
  background-blend-mode: multiply;
  filter: contrast(16)

Pattern variations

In this part, we’re keeping the map gradient unchanged and keeping the same hex values for the pattern gradients, though the pattern gradients themselves change. Depending on the pattern, we might also adjust the contrast.

If you search for halftone patterns online, you’ll see that most of them don’t show a straight grid like we had above. So let’s fix that with a pattern made up of two layers.

--dot: radial-gradient(closest-side, #777, #fff calc(100%/sqrt(2)));
--pattern: var(--dot) 0 0/ 2em 2em, var(--dot) 1em 1em/ 2em 2em

In practice, I’d probably use a variable instead of 2em and compute the offsets for the second layer of dots to be half of that.

real halftone dots pattern

Also, since we’ve increased the size of the dots, we’ve also bumped up the contrast value from 16 to 24.

Another option would be to use a repeating-radial-gradient().

--pattern: repeating-radial-gradient(circle, #777, #fff, #777 1em)
halftone ripples

Something like this can even be animated or made interactive. We can place these halftone ripples at var(--x) var(--y) and change these custom properties on mousemove.

We don’t have to limit ourselves to radial gradients. Linear ones work just as well. We can use a repeating-linear-gradient(), for example:

--pattern: repeating-linear-gradient(#777, #fff, #777 1em)
thinning lines

We can also animate the gradient angle (like in the demo below on hover) or make it change as we move the cursor over the pattern.

We can also restrict the background-size of a linear-gradient():

--pattern: linear-gradient(45deg, #fff, #777) 0 / 1em 1em
triangles

Just like for the first dots pattern variation, here we’ve also bumped up the contrast.

We can also add one extra stop:

--pattern: linear-gradient(45deg, #fff, #777, #fff) 0 / 1em 1em
fragments

For both of the previous ones, the gradient angle can also be animated. This can be seen on hovering the panels in the demo below.

We can also play with conic gradients here. A simple repeating one produces rays that are thicker on the left than on the right.

--pattern: repeating-conic-gradient(#777, #fff, #777 2.5%)

Without any filter adjustment however, the edges of these rays look bad, and so does the middle.

rays, but with ugly edges

Using the tiny blur plus a contrast value that’s 2-3 times the blur tactic fixes the ray edges:

smooth ray edges, but faded pattern edges

… but the pattern’s edges are now faded! We have two possible fixes here.

The first would be to remove the filter from the element itself and apply it on another element stacked on top of it as a backdrop-filter.

The second would be to make the element extend outwards a bit using a negative margin and then clip its edges by the same amount using inset().

Things get a lot more fun if we limit the background-size of such a conic-gradient() pattern and then play with the start angle --a and the end percentage --p.

--pattern: 
  repeating-conic-gradient(var(--a), 
    #fff, #777, #fff var(--p)) 0/ 3em 3em

Map variations

In this part, we’re keeping the pattern constant and trying out different maps.

Our linear-gradient() map doesn’t necessarily need to go along the x axis – it can of course have a variable angle:

--map: linear-gradient(var(--a), #888, #fff)

The demo below shows this angle being animated on hover:

We can also add an extra stop:

--map: linear-gradient(var(--a), #fff, #888, #fff)

Again, hovering the demo below animates the map direction.

We can also make our gradient a repeating one:

--map: 
  repeating-linear-gradient(var(--a), #fff, #888, #fff var(--p))

Or we can switch to a radial-gradient():

--map: 
  radial-gradient(circle at var(--x) var(--y), #888, #fff)

In the demo below, the radial gradient’s position follows the cursor:

The radial gradient can be a repeating one too:

--map: 
  repeating-radial-gradient(circle at var(--x) var(--y), 
    #fff, #888, #fff var(--p))

Same thing goes for conic gradients.

--map: 
  conic-gradient(from var(--a) at var(--x) var(--y), 
    #fff, #888, #fff)

We can use a repeating one and control the number of repetitions as well.

--map: 
  repeating-conic-gradient(from var(--a) at var(--x) var(--y), 
    #fff, #888, #fff var(--p))

One thing that bugs me about some of the map variation demos, particularly about this last one, is the dot distortion. We can make it look less bad by sizing the element with the halftone background such that both its dimensions are multiples of the dot size and change the position in increments of the same dot size.

--d: 1em;
--pattern: 
  radial-gradient(closest-side, #777, #fff) 
    0/ var(--d) var(--d);
--map: 
  repeating-conic-gradient(from var(--a) 
    at round(var(--x), var(--d)) round(var(--y), var(--d)), 
    #fff, #888, #fff var(--p));
width: round(down, 100vw, var(--d));
height: round(down, 100vh, var(--d));

But it’s not enough. In order for our dots to always be perfectly round, we need an SVG filter solution. However, that’s outside the scope of this article, so we’re not discussing it here.

Even more interestingly, our map can be an image too. Taking any random image as it is won’t work well.

using a random image as it is for the map doesn’t work well

We need to bring its saturation down to zero and, for this particular technique, we need to make sure the lightness of its pixels is pretty much in the [50%, 100%] interval.

The filter() function could help here, but, sadly, for almost a decade now, Safari has remained the only browser implementing it. We could make the pattern and the map layer each be a pseudo of an element, blend them together and apply the contrast filter on the pseudo-elements’ parent. This way, the map pseudo could have a filter applied on it too. However, here we’re looking for solutions that don’t involve extra elements or pseudo-elements.

Something we can do is make the map be the result of multiple blended background layers. Making the background-color any grey and blending it with the map image using the luminosity blend mode gives us a result that has the luminosity of the map image on top, the saturation of the background-color below and, since this is a grey (its saturation is 0%), the hue becomes irrelevant.

Note that luminosity is not the same as lightness (which is the ‘L’ in HSL), though in a lot of cases, they’re close enough.

--pattern: 
  radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space;
--map: url(my-image.jpg) 50%/ cover grey;
background: var(--pattern), var(--map);
background-blend-mode: 
  multiply /* between pattern & map */, 
  luminosity /* between map layers */;
filter: contrast(16)

We seem to be going in the right direction.

using a fully desaturated map obtained via blending

But it’s still not what we want, as this desaturated map is too dark, just like the first black to white map gradient we tried.

We can brighten our map using the screen blend mode. Think of this blend mode as being the same as multiply, only with the ends of the lightness interval reversed. multiply always produces a result that’s at least as dark as the darkest of its two inputs, screen always produces a result that’s at least as bright as the brightest of its two inputs.

In our case, if we use screen to blend the desaturated image we got at the previous step with a midway grey like #888, then the result is always at least as bright as #888. And it is #888 only where we blend it with pure black pixels. Wherever we blend it with pixels brighter than pure black, the result is brighter than #888. So basically, we get a map that’s #888 at its darkest, just like our base map gradient.

--pattern: 
  radial-gradient(closest-side, #777, #fff) 0/ 1em 1em space;
--map: 
  conic-gradient(#888 0 0), 
  url(my-image.jpg) 50%/ cover
  grey;
background: var(--pattern), var(--map);
background-blend-mode: 
  multiply /* between pattern & map */, 
  screen /* between map layers */, 
  luminosity /* between map layers */;
filter: contrast(16)

Much better!

using a fully desaturared and brightened map via blending (live demo)

Again, some of the dots aren’t fully round, but in order to get fully round dots, we’d need an SVG filter and that’s a way too big of a topic to discuss here.

Palette variations

The simplest possible variation would be having white halftone dots on a black background. To do this, we can simply chain invert(1) to our filter.

Or… we can do something else! We can use the screen blend mode we’ve used before to brighten the image map. As mentioned, this works like multiply, but with the ends of the lightness interval reversed. So let’s reverse them for both the pattern and the map.

background: 
  var(--pattern, 
    radial-gradient(closest-side, #888, #000) 0/ 1em 1em space), 
  var(--map, 
    linear-gradient(90deg, #777, #000));
background-blend-mode: screen;
filter: contrast(16)
inverted halftone dots pattern (live demo)

But we’re not limited to just black and white.

Remember the part about how contrast works? Large contrast values push all pixels of the filter input to one of 8 possible RGB values. So far, our filter input has been just greys, so they got pushed to either black or white. But we don’t necessarily need to have just greys there. We could tweak those values to either zero or max out a channel or two everywhere.

For example, if we max out one of the channels, then our black dots get that channel added to them. Maxing out the red channel gives us red dots, maxing out the blue channel gives us blue dots, maxing out both the red and blue channels gives us magenta dots.

Going the other way, if we zero one of the channels, then it gets subtracted out of the white background. Zeroing the blue channel gives us a yellow background (the red and green channels are still maxed out for the background and combined, they give yellow). Zeroing the red channel gives us a cyan background. Zeroing both the blue and green channels gives us a red background.

You can play with various scenarios in the interactive demo below:

We can of course also have more interesting palettes and we can even have halftone dots on top of image backgrounds using the pure CSS blending technique I detailed in a talk on the topic I used to give in 2020 or by using SVG filters. Both of these approaches however require more than just one element with no pseudos and three CSS properties, so we won’t be going into details about them here.

Combining these variations (and more!)

Varying more than one of the above can help with interesting results.

By using top to bottom linear gradients for both the pattern and the map, with the pattern one having its size limited to 10% of the element, we can get the effect below without needing to use a mask gradient with many irregulrly placed stops. Blending with some extra layers helps us with a nicer palette for the final result.

We can also animate a map’s background-position to get a blinds effect like below:

In the demo above, we’ve also blended the halftone pattern with an image. Here’s another such example (note that this doesn’t work in Firefox due to bug 1481498, which has everything to do with the text on the right side and nothing to do with the halftone part):

card with halftone effect (live demo)

Note that the code for all these demos so far is heavily commented, explaining the purpose of pretty much every CSS declaration in there.

The example below uses a repeating-radial-gradient() pattern and a conic-gradient() map, which funny enough, also creates a tiny heart in the middle.

For a bit of a different effect, here’s a rhombic halftone one created by using two blended layers for the map – two otherwise identical linear gradients going in different directions:

The demo below is a combination of two halftone patterns stacked one on top of the other, the top one being masked using a conic-gradient() checkerboard mask.

Here are a few more halftone samples as card backgrounds:

Even more such halftone samples can be found in this gallery:

We aren’t limited to 2D. We can also use such paterns in 3D and even animate them.

excavated cube with animated halftone (live demo)

Finally, even more demos showcasing halftone patterns can be found in this CodePen collection:

the CodePen collection

]]>
https://frontendmasters.com/blog/pure-css-halftone-effect-in-3-declarations/feed/ 8 4594