Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Wed, 01 Oct 2025 16:05:08 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 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
blur(1px) blur(1px) https://frontendmasters.com/blog/blur1px-blur1px/ https://frontendmasters.com/blog/blur1px-blur1px/#respond Mon, 07 Jul 2025 21:36:33 +0000 https://frontendmasters.com/blog/?p=6488 I know that you can “chain” the CSS property filter, like:

.element {
  filter: blur(2px) grayscale(80%) contrast(200%);
}

All three of those filters will apply. But somehow I never thought about applying the same filter more than once. That also works, and they don’t override each other, they “stack” (or whatever you want to call it).

So here’s some useless bar trivia for you. What is blurrier, filter: blur(2px) or filter: blur(1px) blur(1px)? Answer.

]]>
https://frontendmasters.com/blog/blur1px-blur1px/feed/ 0 6488
Iterator Helpers Supported Across all Browsers https://frontendmasters.com/blog/iterator-helpers-supported-across-all-browsers/ https://frontendmasters.com/blog/iterator-helpers-supported-across-all-browsers/#respond Fri, 27 Jun 2025 21:44:14 +0000 https://frontendmasters.com/blog/?p=6387 Feels notable that Iterator helpers have become Baseline Newly available. The gist is that you can map and filter on stuff that was annoying or impossible to before. I’ll copy Jeremy Wagner’s example:

const posts = document.querySelectorAll("ul#specific-list > li")
  .values()
  .filter(item => item.textContent.includes("kiwi"));

// For-of loops can only be used on iterables, which `posts` is!
for (const post of posts) {
  console.log(post.textContent);
}
]]>
https://frontendmasters.com/blog/iterator-helpers-supported-across-all-browsers/feed/ 0 6387
CSS Spotlight Effect https://frontendmasters.com/blog/css-spotlight-effect/ https://frontendmasters.com/blog/css-spotlight-effect/#comments Mon, 26 May 2025 15:02:35 +0000 https://frontendmasters.com/blog/?p=5939 I recently made an experiment about Proximity Reactions. The idea was to create an interactive effect according to the mouse position relative to elements. Then I made a less JavaScript, more CSS version where the only thing JavaScript does is to pass the mouse position into a couple of CSS custom properties. That’s it. All the heavy lifting happened inside the CSS itself, safely away from the JavaScript thread.

That got me thinking: if we can get the mouse position in CSS so easily, what else can we build with that? I started tinkering, trying out different interaction patterns, and eventually got to this Spotlight Effect that’s easy to create, simple to customize, and looks surprisingly slick, all with just a few lines of CSS.

Let’s take a look at how it works and how you can make it your own, and hopefully you can pick up a few new CSS tricks along the way. 🙂

The Setup

To create a spotlight effect that responds to the mouse position, we need to set up two small things before diving into the CSS.

  1. We need a dedicated spotlight element in the DOM. This is usually placed near the end of the markup so it can sit on top of everything else when needed.
  2. We need just a few lines of JavaScript to pass the mouse coordinates into CSS custom properties.
<div class="spotlight"></div>
document.body.addEventListener('mousemove', (e) => {
  document.body.style.setProperty('--clientX', e.clientX + 'px');
  document.body.style.setProperty('--clientY', e.clientY + 'px');
});

That is all. No fancy libraries, no event throttling, just raw coordinates handed over to CSS, where the real magic happens.

Basic follow

Now that the setup is in place, we can start writing some CSS. We will begin with a very basic version of the spotlight effect: a simple transparent circle that follows the mouse movements. There are many ways to implement this kind of effect. Using transform is a common and often more precise approach in some cases. But for our example, we are going to tap into the power of background-image. This gives us a lot of creative flexibility, especially when we’ll start creating patterns with gradients later on.

Here is the CSS for our initial spotlight:

.spotlight {
  position: fixed;
  inset: 0;
  background-image: radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent 6em, black 8em);
}

Notice that we set position: fixed and inset: 0, this ensures that it fills the entire viewport, anchoring it to the edges of the body, and stays in place when the user scroll down the page. With that in place, we can position the transparent circle (made with a simple radial-gradient) using the CSS custom properties that our JavaScript sets. It really is that simple.

I’m using em units for sizing. This makes everything scale relative to the font size, and it makes it very easy to adjust the size of the entire effect just by changing the font size on this element.

Here is the result:

To make the effect feel a bit lighter, I also added a touch of opacity. I think it creates a more layered and subtle look. More importantly, I set pointer-events: none on the .spotlight element. Since this layer sits above everything else in the DOM, we want to make sure it does not block any user interaction with the elements below it. Without this, buttons, links, and other interactive parts of the page would become unresponsive.

I’m not using cursor: none; here. While it might seem like an good choice for effects like this, hiding the mouse cursor can lead to accessibility issues and negatively impact the user experience. It’s generally best to avoid it.

Making It Interesting

This is where things start to get fun. Instead of a simple circle, we can turn our spotlight into a dynamic, interactive effect that responds to the mouse movement in playful ways. The technique we will use involves layering gradients in the background-image and combining them in a gooey visual style. The result is a smooth, organic animation that feels alive under the cursor.

To achieve the gooey effect, we rely on the filter property, specifically a combination of blur and contrast. The blur softens the edges of the shapes, and the high contrast causes overlapping areas to merge into blobs. However, applying contrast on a transparent background does nothing. To fix that, we give the element a solid white background-color. Then, to make the white areas effectively transparent against the page, we use mix-blend-mode: darken.

1) Start with a basic spotlight

Add a pattern using the background-image

Set the background-color to white

Apply the filter for the gooey effect

Remove the white parts using mix-blend-mode

And here is the code that sets up this visual base:

.spotlight {
  filter: blur(1em) contrast(100);
  mix-blend-mode: darken;
  background-color: white;
}

Now that we have this setup, we can start layering more shapes, play with gradients, and watch the gooey interactions evolve as the mouse moves.

The Blob Light

With the gooey base in place, we can use gradients to build more playful visual behaviors. Since background-image can accept a comma-separated list of layers, we can stack several gradients with varying styles, sizes, and positions. These layers blend together through the blur and contrast filters, resulting in a smooth, organic effect.

To create a blob-style spotlight, I made the main circle a bit larger and softer, and stacked two repeating linear gradients to form a diagonal grid pattern.

.spotlight {
  background-image:
    radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent, black 20em),
    repeating-linear-gradient(45deg, black 0 0.4em, transparent 0 3em),
    repeating-linear-gradient(-45deg, black 0 0.4em, transparent 0 3em);
}

This is how the background-image looks like without the gooey setup:

And the full blob effect:

Fixing the Fuzzy Edges

You may have noticed in the previous example that the edges of the .spotlight element appear fuzzy, subtly revealing the content behind it. This is a side effect of the blur filter. When there’s nothing beyond the blurred edge for the contrast filter to respond to, the gradient just fades out softly. Visually, that results in blurry borders that break the clean feel of the effect.

There are a few ways to deal with this. Like scaling up the element, applying a negative inset, or manually setting a larger width and height. But all of these approaches introduce extra complexity, especially since you’d also have to compensate for the mouse coordinates shifting relative to the larger area.

A simpler and more robust fix is to add an outline. Just make sure it’s larger than the blur radius and matches the background color. That way, the fuzzy edges get hidden cleanly without affecting the positioning logic at all.

.spotlight {
  outline: 2em solid white;
}

We’ll include this outline fix in all the following examples to keep things clean and crisp.

Dotted Reveal

The reason the blob in the previous example appears to morph as the mouse moves is that, while the main circle follows the cursor, the grid pattern remains fixed on the screen. The interaction between these two layers creates the illusion of motion and shifting shapes within the spotlight.

Following the same principle, we can build a dotted effect. This time, instead of diagonal lines, we’ll use two radial gradients, and set a background-size to create a repeating pattern:

.spotlight {
  background-image:
    radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent 6em, black 10em),
    radial-gradient(circle, black 0.2em, transparent 1em),
    radial-gradient(circle, black 0.2em, transparent 1em);
  background-size: 100% 100%, 2em 3em, 2em 3em;
  background-position: 0 0, 0 0, 1em 1.5em;
}

The first layer defines the moving mask (just like before), and the next two layers form the repeating dot pattern. By adjusting background-position, we offset the second dot layer to create the alternating effect. The result is a playful dotted texture that dynamically follows the mouse.

All of the values in the last two examples (color stops, gradient sizes and positions, blur and contrast settings, and more) can be tweaked to create wildly different effects. I spent a lot of time experimenting before landing on these particular numbers, and I encourage you to do the same. Go ahead and fork one of the demos, adjust the gradients, play with the filter values, and see where your creativity takes you. And if you discover something cool, don’t forget to send it my way.

Movement Interaction

In the previous examples, only the main circle responded to the cursor movement, but those same CSS variables can drive other visual elements as well. Here is an example that lays out a grid of squares using a conic-gradient. By offsetting its position by a fraction of the cursor coordinates (a factor of negative 0.25 in this case) we achieve a subtle parallax effect.

.spotlight {
  background-image:
    radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent, black 14em),
    conic-gradient(from 270deg at 1em 1em, #aaa 90deg, transparent 0);
  background-size: 100% 100%, 3em 3em;
  background-position: 
    0 0, 
    calc(var(--clientX, 50%) * -0.25) calc(var(--clientY, 50%) * -0.25); /* only the conic layer moves */
}

You can comment out the background-position to see its affect, and feel free to tweak the offset factor and see how the motion transforms.

Tip: try adding a transition on the background’s position to add even more motion. .spotlight { transition: background-position 0.5s ease-out; }

Remember these values can be used for anything. They’re just variables, and that means you can plug them into any CSS property that accepts dynamic values. For example, here’s an example where the mouse’s X position controls the amount of blur, and the Y position determines the size of the central circle.

With just two custom properties, you’re suddenly controlling not only movement, but also style and intensity. You could just as easily hook the mouse into opacity, gradient angles, or any part of the effect you want to feel dynamic. What would you change in your effect?

The Full Reveal

Up until now, we’ve been revealing only what’s inside the spotlight, with everything else hidden behind the dark blur. But what if we want to fully reveal the page in certain cases? For example, when hovering over a specific element, we might want to turn the effect off entirely and let the full content show.

Surprisingly, you don’t need any JavaScript to do this. With one clever CSS selector, we can ‘listen’ for a hover on elements with a specific class and adjust the effect accordingly.

.spotlight {
  transition: opacity 1s, background-color 1s;

  body:has(.reveal:hover) & {
    opacity: 0;
    background-color: black;
  }
}

Now, any element with class="reveal" will temporarily disable the spotlight effect when hovered.

In terms of styling, there are a few ways to disable the effect. You could scale the gradient out, reduce the blur, or even hide the entire .spotlight element. In this case, I went with a combination of lowering the opacity and changing the background color. This gave me a subtle fade effect both in and out.

The Light Spotlight

Until now, the hidden part of the page has been covered in black, creating a dark spotlight effect. But what if your design calls for a light version, with white as the cover color?

Turns out it’s pretty straightforward. All we need to do is invert the colors in our .spotlight element’s styles. Anything that was black becomes white, anything that was white becomes black (transparent stays as-is). And just as important, make sure to change the mix-blend-mode from darken to lighten so that the blending works correctly with the inverted color scheme.

Of course, these values don’t have to be hard coded. You can define the colors and blend mode using CSS custom properties, giving you full control over the theme. Better yet, we can respond to user preferences using the light-dark() function and the prefers-color-scheme query to decide whether to use a light or dark spotlight effect.

:root {
  color-scheme: light dark;

  --spotlight-cover: light-dark(white, black);
  --spotlight-reveal: light-dark(black, white);

  @media (prefers-color-scheme: dark) {
    --spotlight-blend-mode: darken;
  }
  
  @media (prefers-color-scheme: light) {
    --spotlight-blend-mode: lighten;
  }
}

This approach not only makes your spotlight more flexible, but also keeps it aligned with accessibility and user experience best practices.

Adding Colors

So what about colors beyond just black or white? Black and white are great for creating strong contrast, but what if you want something a bit more… purple?

Well, at this point, we need to slightly rethink our approach. The gooey technique we’ve used so far works beautifully with monochrome because of the way mix-blend-mode interacts with light and dark. As soon as you start introducing color, things get trickier. The blend mode can dramatically shift the look and feel depending on how your chosen colors interact with the background and with each other.

You can try changing the colors to something like purple or teal, but it will alter the nature of the effect, sometimes in surprising ways, so I encourage you to experiment. And how knows, you might land on exactly the vibe you’re looking for.

Mobile Support

This entire effect relies on mouse movement, so what happens when there’s no mouse? Rather than hiding content on touch devices, we’ll simply disable the effect altogether when we detect a mobile or touch-based screen. That way, users still see everything, just without the fancy spotlight interaction.

We can ensures that a device support hover interactions using the hover media query, which is supported on all major browsers. By wrapping the spotlight styles in a @media (hover: hover) we can apply the effect only on hover supported devices.

@media (hover: hover) {
  .spotlight {
    /* spotlight styles */
  }
}

This media query works well for most cases, but some devices support both touch and mouse input. Think touchscreen laptops or tablets with external mice. In those cases, the effect might kick in when it shouldn’t.

To handle this more gracefully, we can back up our CSS with a small JavaScript snippet. It listens for a touch event and disables the effect as soon as a user interacts via touch. That way, the spotlight effect is removed dynamically if the device leans toward touch input.

const mouseMoveHandler = (e) => {
  document.body.style.setProperty('--clientX', e.clientX + 'px');
  document.body.style.setProperty('--clientY', e.clientY + 'px');
};

document.body.addEventListener('mousemove', mouseMoveHandler);

document.body.addEventListener('touchstart', () => {
  document.body.classList.add('reveal');
  document.body.removeEventListener('mousemove', mouseMoveHandler);
});

And one last thing on this topic: we should also account for basic keyboard navigation. We do not want users tabbing into elements that are visually hidden by the effect, so we will also disable it in that case. This can be detected using body:has(:focus-visible), which tells us when one of our elements is focused. You can combine this selector with your .reveal logic to ensure the effect is turned off when keyboard navigation kicks in.

@media (hover: hover) {
  .spotlight {
    /* spotlight styles */

    body:has(.reveal:hover, :focus-visible) & {
      opacity: 0;
      background-color: black;
    }
  }
}

With this setup, the effect behaves just right: it kicks in only when it makes sense and stays out of the way when it doesn’t. Mobile users still get the full content, and hybrid devices adapt in real time.

The Ultimate Spot

Before we wrap up, here is a quick demo that brings together most of what we explored. A spotlight with a blob gooey effect, crisp edges, theme switching, and full mobile and keyboard navigation support. All within scrollable content, with areas that disable the effect on hover.

Taking It Further

All of the ideas in this article are just starting points. Now it’s your turn to run with them. You can play with gradient backgrounds and tweak their sizes and positions. You can experiment with filter settings or try different blend mode options to see what new moods emerge. You might also pull extra data from JavaScript (like the cursor angle relative to an element or the speed of movement) and feed that into your styles for even richer effects.

In this article, I’ve used a single <div> for the .spotlight element, but feel free to layer in additional elements, icons, text, or graphic shapes within the reveal area. Apply the same technique to multiple elements with their own custom settings. The possibilities are endless, so let your imagination guide you and discover what unique interactions you can build.

]]>
https://frontendmasters.com/blog/css-spotlight-effect/feed/ 8 5939
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