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
Replace Your Animated GIFs with SVGs https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/ https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/#comments Mon, 15 Sep 2025 16:12:53 +0000 https://frontendmasters.com/blog/?p=7112 ` or `background-image`, making it a viable GIF replacement if you can pull it off! ]]> No one loves dancing hamster GIFs more than I do. But all those animated frames can add up to files so large you don’t even see the dancing hamster. Your other tab has already loaded and you’ve followed the dopamine hits down another social media rabbit hole.

There’s an alternative for those giant animated GIFs: animated SVGs.

Along with much smaller file size you also get infinite scalability and the use of some — though, sadly, not all — media queries. Let’s take a look.

Warning: some of the animations in this article do not use a prefers-reduced-motion media query. We’ll discuss why that is later in the article.

How it works

First let’s create a simple rhombus in SVG. You could do a square, but rhombus is more fun to say.

<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 500 500">
  <path id="rhombus" fill="#fc0000" d="m454 80-68 340H46l68-340h340Z"/>
</svg>

Next let’s do a quick spinning motion that we’ll run infinitely.

#rhombus {
  transform-origin: center;
  rotate: 0deg;
  animation: spinny-spin 3.5s forwards infinite ease-in-out;
}
@keyframes spinny-spin {
  0% {
    rotate: 0deg;
  }
  90%, 100% {
    rotate: 720deg;
  }
}

We’ve done this as essentially a separate CSS file that looks into the SVG to style parts of it. We could pluck up that CSS and put it right inside the <svg> if we wanted. SVG is cool with that.

<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round"
    stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 0 500 500">
    <style>
        #rhombus {
            transform-origin: center;
            rotate: 0deg;
            animation: spinny-spin 3.5s forwards infinite ease-in-out;
        }
        @keyframes spinny-spin {
            0% { rotate: 0deg; }
            90%, 100% { rotate: 720deg; }
        }
    </style>
    <path id="rhombus" fill="#fc0000" d="m454 80-68 340H46l68-340h340Z" />
</svg>

Now that the SVG is all one contained thing, we could save it as an independent file (let’s call it rhombus.svg) and load it using an <img> element:

<img src="rhombus.svg" alt="a spinning red rhombus">

Even when loaded in an img, the animation still runs (!):

This is why this technique is viable as a .gif replacement.

This technique works best for animations that move and/or transform the elements as opposed to a sprite-based or successive image animation (which is basically what an animated GIF is). Also, for security reasons, an SVG loaded through an img element can not load external files i.e., the sprite image. You could base64 the sprite and embed it, but it would likely increase the file size to animated GIF levels anyway.

Let’s look at a more complicated example:

Here’s a zombie playing an accordion (yes, it’s random, unless you know about my silly little site, then it’s still random, but not unexpected). On the left is the GIF version. On the right is the SVG.

GIF
SVG

As an animated GIF, this polka-playing image is about 353Kb in size, but as an animated SVG it’s just 6Kb, less than 2% of the GIF’s size. That’s massive size (performance) savings with the SVG, while looking crisper doing it.

I drew the character in a graphics program and outputted it as an SVG. I used Affinity Designer but you could use Adobe Illustrator, Inkscape, Figma, or anything else that exports SVG.

Side Note: In my export, I made certain to check the “reduce transformations” box in order to make it easier to animate it. If you don’t reduce transformations, the elements can appear in all kinds of cockamamie contortions: scaled, translated and rotated. This is fine if the element is static, but if you want to move it in any way with transformations, you’ll have to figure out how editing the transformations will affect your element. It almost certainly won’t be straightforward and may not even be decipherable. With reduced transformations, you get an element in its natural state. You can then transform it in whatever way you need to.

After outputting, I created the CSS animation using @keyframes then added that to an SVG style element (which works just about exactly the same as an HTML style element).

See Complete SVG File
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" fill-rule="evenodd" stroke-linejoin="round" stroke-miterlimit="2" clip-rule="evenodd" viewBox="0 20 250 440">
  <style>
    /* Music Note Animation */
    .musicnote {
      animation: 4s ease-in-out calc(var(--multiplier) * 1s - 1s) notes forwards infinite;
      opacity: 0;
    }
    /* These custom properties allow for some variation in the timing of each note so that there animations overlap and seem more random while also allowing one set of keyframes to be used for all seven notes */
    #n1 { --multiplier: 1 }
    #n2 { --multiplier: 1.2 }
    #n3 { --multiplier: 1.4 }
    #n4 { --multiplier: 1.6 }
    #n5 { --multiplier: 1.8 }
    #n6 { --multiplier: 2 }
    #n7 { --multiplier: 2.2 }
    @keyframes notes {
      /* move the notes up 2em while also varying their opacity */
      0% {
        opacity: 0;
        transform: translateY(0);
      }
      30%, 80% {
        opacity: 1;
      }
      100% {
        opacity: 0;
        transform: translateY(-2em);
      }
    }
    #zright, #zleft {
      /* Sets the initial state for each hand and side of the accordion */
      --multiplier: -1;
      transform-origin: 0 0;
      transform: translateX(0) rotate(0);
      animation: 4s ease-in-out 0s play forwards infinite;
    }
    #zleft {
      /* allows the same keyframes to be used for both sides by reversing the translation and rotation */
      --multiplier: 1;
    }
    @keyframes play {
      0%, 100% {
        transform: translateX(0) rotate(0);
      }
      50% {
        transform: translate(calc(var(--multiplier) * 31px), calc(var(--multiplier) * -1px)) rotate(calc(var(--multiplier) * 2deg));
      }
    }
    /* Animates the squeeze and stretch of the accordion bellows */
    #accord {
      animation: 4s linear 0s squeeze forwards infinite;
      transform-origin: center center;
      transform: scaleX(1);
    }
    @keyframes squeeze {
      0%, 100% {
        transform: scaleX(1);
      }
      50% {
        transform: scaleX(0.8);
      }
    }
  </style>
<g id="zombie">
  <!-- The main zombie head and body, everything except the hands -->
  <path fill="#676767" fill-rule="nonzero" d="M62 207h121v47H62z" />
  <path fill="#91c1a3" fill-rule="nonzero" d="M99 190h46v26H99z" />
  <path fill="#3a3a3a" fill-rule="nonzero" d="M156 87h10v19H78V87h9v-9h69v9Z" />
  <path fill="#9cd3b3" fill-rule="nonzero"
    d="M155 105h9v18h19v29h-10v27h-9v9h-9v10h-18v9h-29v-9H90v-10H80v-9h-9v-27h-9v-29h18v-18h10v-9h65v9Z" />
  <path id="eyes" fill="#fbeb8e" fill-rule="nonzero" d="M127 114h31v28h-31zm-37 0h28v28H90z" />
  <path fill="#758b7c" fill-rule="nonzero" d="M108 170h11v9h-11z" />
  <path fill="#91c1a3" fill-rule="nonzero" d="M118 133h9v28h-9z" />
  <path fill="#444445" fill-rule="nonzero" d="M90 123h9v9h-9zm46-9h9v9h-9z" />
  <path fill="#3a3a3a" fill-rule="nonzero" d="M164 102h9v39h-9zm-93 0h9v39h-9z" />
  <path fill="#676767" fill-rule="nonzero" d="M118 393v57H46v-37h34v-58h38v38Z" />
  <path fill="#9cd3b3" fill-rule="nonzero" d="M80 384h38v10H80z" />
  <path fill="#676767" fill-rule="nonzero" d="M128 393v-38h38v58h34v37h-72v-57Z" />
  <path fill="#9cd3b3" fill-rule="nonzero" d="M128 384h38v10h-38z" />
</g>
  <g id="accord">
<!-- THe accordion bellows -->
    <path fill="#9e6330"
      d="m191 201-20 7-25 7-20 4-25-2-25-7-24-3-14-2-18 147 23 9 24 6 29 5 30 4 25-6 27-8 29-1 17-9-19-152-14 1Z" />
    <path fill="#774b24"
      d="m107 214-10-1-6 162 10 1 6-162Zm14 2h10v162h-10zm31-5-10 1 4 162 10-1-4-162Zm23-6h-10l7 162h10l-7-162Zm20-8-10 1 17 166 10-1-17-166ZM81 208l-10-1-10 162h10l10-161Zm-20-5-10-1-16 162 10 1 16-162Z" />
  </g>
<g id="notes">
  <!-- The seven musical notes -->
  <path id="n7" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m200 153 5-23 2 1 2 1v1h2l-1 1h2l4 1 1 1v1h2l-1 2h1v1l2 1h1l-1 2-1-1v1h-4l1-1h-2v-1h-4l-1 1-3-1v-1h-2l-4 19h-1v2h-1l-1 1-1 1-2-1v1h-3v-1l-2-1v-1h-1l1-1h-1v-1h6v-1h2v-1h3v-1h-5v1h-2v-1l-1 1v-1l-3 1h-1l1-2h1v-2h1v-1h1v-1l2 1 1-1h3l-1 1 3 1-1 1h1Z" />
  <path id="n6" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m16 78 3-23h2v1h3v1h1v1h6v1h1v2h2l-1 2h1v1h2v1h1v1h-3v1h-2v-1h-2v-1h-2v1l-2-1v1h-4v-1h-2l-2 19h-1l-1 2h-1v1h-1v1h-2v1l-3-1v-1H7v-1H6v-1H5v-1h2v-1h5v-1h1v-1h3v-1h-4v1H5h1l-1 1v-2h1v-2h1v-1h1v-1h2l1-1h3l-1 1 3 1-1 1h1Z" />
  <path id="n5" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m40 111 1-7h2l-1 13h-1v2h-1v1h-1v1h-2v1h-4l1-1h-2v-1l-1-1v-1h-1v-2h-1v-3h1v-2h1v-1h1l1-1h2v-1l3 1v1h2v1Zm3-13-1 6h-2l1-16h2v1h3v1h1v1h6v1h1v1h2v2h1v1h1v1h1v1h-1v1h-4l-1-1h-5v1l-3-1h-2Z" />
  <path id="n4" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="M220 79h2l1 1 1-1v1h2l-1-1h6v1h2v2h1l1 1 1-1v1h1l1 1h-2l1 1h-2v1h-4v-1l-2 1-2 1v1h-6l2 7 1-1 2 13h-1l1 2-1 1v1h-1v1h-2v1l-3 1v-1h-2v-1h-1v-1h-1l-1-2-1 1v-3l1-1-1-2h1v-1h1v-1h2v-1l3-1v1h2v1l1-1-1-6h-2l-3-16 2-1 1 1Z" />
  <path id="n3" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="M226 183v-7h2l-1 13h-1v2h-1v1h-1v1h-2v1h-3v-1l-2-1v-1h-1v-1h-1v-2h-1v-3h1v-2h2v-1h1v-1h2v-1l3 1v1h2v1h1Zm2-14-1 7h-2l1-16h2l3 1v1h1v1h2v-1l4 1v1h1v1h2v2h1v1h1v1h1v1h-2v1h-3l1-1h-2l-3-1v1h-5v-1h-2Z" />
  <path id="n2" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m23 164 2 13h-1v2l-1 1 1 1h-1v1h-2v1l-3 1v-1h-2l-1-1h-1v-1h-1v-2h-1l-1-3h1v-2h1v-1h1v-1l2-1v-1h3v1h2v1l1-1-1-6 2-1Zm-1-15 2-1 1 1h1v1h1v-1l5-1v1h1v1h2v2h1l1 1 1-1v1h1v1h-1v1h-1v1h-2v-1l-2 1v-1l-3 1h1l-2 1-4 1v-1h-2m-2-9h-2l3 17 2-1-3-16Z" />
  <path id="n1" class="musicnote" fill="#2f2f2f" fill-rule="nonzero"
    d="m188 72 3 13h-1v2h-1v2l-2 1v1h-3v-1l-2 1-1-1h-1v-1h-1v-2l-1 1-1-3 1-1v-2h1l-1-1h1v-1l2-1v-1h3v1l2-1 1 1-1-7h2Zm-2-16h3v1l1-1v1h2l4-1h1l1 1h1l1 2h1v1l1-1 1 1h1v1h-1v1h-1v1h-4l-1-1-2 1-2 1v1h-5l1 7h-2l-3-16h2Z" />
</g>
  <g id="zleft">
    <!-- The left hand and left side of the accordion -->
    <path fill="#676767" d="m11 255-5-1-2 17v19l10 22 9-49-12-8Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m15 257 4-16-9-2-4 16 9 2Z" />
    <path fill="#581610" d="m11 294 7-87h6v-5h6l1-5h6v-5l6 1-16 184h-6v-5l-5-1v-5l-6-1v-5l-5-1 6-65Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m24 283-1 9-19-2 2-35 20 2v8l8 1-1 18-9-1Z" />
    <path fill="#330d09" d="M17 249h7l-5 51h-7l5-51Z" />
    <path fill="#48120d" d="m16 256 7 1-3 36-7-1 3-36Z" />
    <path fill="#6b6108" d="M19 251h3l-1 3-2-1v-2Zm-4 44h3l-1 3h-2v-3Z" />
  </g>
  <g id="zright">
    <!-- The right hand and right side of the accordion -->
    <path fill="#676767" d="M237 253h3l4 18 2 19-11 25-11-51 13-11Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m231 258-6-15 8-3 6 15-8 3Z" />
    <path fill="#581610" d="m224 366-27-176 6-1 1 5h6l1 5 6-1v5l6-1 14 89 8 55-5 2v5l-6 1 1 5-6 1 1 5-6 1Z" />
    <path fill="#9cd3b3" fill-rule="nonzero" d="m221 266-2-8 20-3 5 35-20 2-1-8-9 1-2-17 9-2Z" />
    <path fill="#330d09" d="m228 249-7 1 8 51 7-1-8-51Z" />
    <path fill="#48120d" d="m229 256-7 2 6 36 7-1-6-37Z" />
    <path fill="#6b6108" d="m226 251-3 1 1 3 3-1-1-3Zm7 44-3 1 1 3 3-1-1-3Z" />
  </g>
</svg>

Then, you save the SVG as a file and bring it into the webpage with an HTML img tag.

<img src="zombieaccordion.svg" alt="A zombie playing ear-bleeding notes on an accordion">

Background Images

These animated SVGs don’t just work in the img element, they also work as a CSS background-image. So you can have a hundred little zombies playing the accordion in your background. That said, repeating the animation potentially infinitely takes a hit on page performance. For instance, during testing, when I had the zombie playing as a background image, another copy of the SVG in an img element struggled to animate.

Media Queries

Some media queries from within the SVG pierce the vale and work normally! Queries for width, height and even prefers-color-scheme worked just fine. But it’s a mixed bag. I couldn’t get print, pointer or, worst of all, prefers-reduced-motion to work. But those media queries that do work can give you even more flexibility in how you work with these animated SVGs.

Using @media (max-width: 300px), the animation below only plays when the img is 300 pixels wide or larger. To be clear, the max-width media query is based on the size of the img element, and not the size of the screen.

Plus media queries work even in background images! They can be a little trickier because, for instance, the width queries work on the size the image appears at, not the size of the container.

Gotchas

While most of this works the way any other CSS animation would, there are some limitations to how the CSS works in the SVG file shown in img vs. how it would work embedded in the HTML directly. As replaced content, the SVG is in a sort of sandbox and cannot access much outside the file.

  • The animation has to run automatically. You can’t use hover effects or clicks to start the animation.
  • Despite width and height media queries working within the SVG, viewport units do not work within the SVG.
  • As mentioned above, the animation won’t recognize prefers-reduced-motion, whether the prefers-reduced-motion declaration is within the SVG or in the larger site. While neither would an animated GIF recognize it, it unfortunately won’t give you that additional built-in functionality. On the plus side, any system you had that would prevent an animated GIF from playing should be easily modifiable to also apply to the SVG.
  • The SVG won’t run JavaScript from within the SVG. While a GIF wouldn’t run JavaScript either, I had hoped to get around prefers-reduced-motion not working by implementing it with JavaScript, but that too didn’t work. It’s probably a good thing it doesn’t, though, as that would be a massive security hole.
  • Modern CSS may or may not work. I was delighted to see custom properties and nested selectors working fine in my tests, but exactly what modern features are available and what may not work (like prefers-reduced-motion) will require more testing.

This technique works in all versions of the latest browsers and should theoretically work as far back as style elements and CSS animations are supported in SVG.

Alright let’s get those hamsters… errr… zombies dancing!

]]>
https://frontendmasters.com/blog/replace-your-animated-gifs-with-svgs/feed/ 1 7112
The Figcaption Problem https://frontendmasters.com/blog/the-figcaption-problem/ https://frontendmasters.com/blog/the-figcaption-problem/#comments Thu, 24 Jul 2025 19:44:54 +0000 https://frontendmasters.com/blog/?p=6532 There is this problem with this, when it comes to layout:

<figure>
  <img src="image.jpg" alt="good description of image" />
  <figcaption>This is a pretty long caption that I want for the image. It's such a long bit of text that it's likely going to wrap in the layout.</figcaption>
</figure>

The problem isn’t with the HTML, that’s fine.

The problem is when the image is less wide than the container and we want the figcaption to only be as wide as the image is.

We want this:

We want that orange buddy. That’s the <figure> element sitting in the middle of this article, centered, with an <img> inside that isn’t the full width of the article, and a <figcaption> inside that wraps at the edges of the image.

How hard can that be?!

Well — it certainly is weird.

This all started with a post from Jeff Bridgforth that piqued my interest:

Has anyone come up with a trick to make the width of a figure element fit the content of contained image even when figcaption has more width? I want figcaption to wrap. @kevinpowell.co @bell.bz @ishadeed.com @michelleb.bsky.social @chriscoyier.net @5t3ph.bsky.social

Jeff Bridgforth (@jeffbridgforth.com) 2025-06-09T14:08:35.238Z

See, I’d run into this myself. On my own blog, I often post photos that are not the full width of the page and want to center them or float them to a side or something. And the thing that limits the width of the <figcaption> is the parent <figure> itself, not the <img>. So how do you limit the <figcaption> width?

On my own blog, I was just like screw it and set a max-inline-size on them.

Me going, eh, screw it: figcaption { max-inline-size: 300px; }

For the most part I chalked it up as a design decision that had kind of a cool look. But it still bugged me. Like the image above where the figcaption still ends up wider than the image.

There is a proper solution here.

Jeff was smart enough to blog the entire conversation and solutions that came out of his post. And frankly he did a good job and this blog post probably isn’t entirely necessary. But hey if it helps more people when they run into this, that’s cool.

Allow me to jump straight to the end and showcase the best solution, by Stephanie Eckles:

There it is, the perfect solution here.

figure {
  inline-size: fit-content;
  margin-inline: auto;
}
figcaption {
  contain: inline-size;
}

/* Probably in your reset stylesheet, which is good. */
img {
  max-width: 100%; 
}

Wouldn’t you think you could just min-content the <figure>?

Like:

figure {
  inline-size: min-content;
}

That’s what my brain does and I heard from others the same. The image would be the smallest content within the figure (otherwise it would be just a word), so the figure should kinda shrink-wrap around the image.

The thing is… you can and it works… unless… you use the classic reset stylesheet thing:

img {
  max-width: 100%;
}

I’m a fan of this. It’s protection against a too-wide image busting out of a container. It’s a classic, and it’s important. This is more like reality, where width and height attributes are on the image, because that’s a best-practice for maintaining aspect ratio space as the image is loading.

<img src="..." alt="..." width="4000" height="2000" />
img {
  /* prevent blowouts */
  max-width: 100%;

  /* maintain aspect ratio */
  height: auto;

  /* opinionated, but removes line-height space below block images */
  display: block;
}

But if we do this, we’re essentially wiping away the intinstic size of the image and the min-content width becomes based on the figcaption instead and we get smashy-smashy thin time:

A small image of flowers with a caption below describing the content.
Nope.

What’s with mixing logical properties like inline-size in some places and non-logical properties like max-width in others? I’m a fan of almost always using logical properties, but for most images, even changing to a vertical writing mode shouldn’t rotate images, so properties like width make sense.

The Best Tricks Are About Using The Images Intrinsic Size Instead Of The Figcaption

The core of the trick is:

figcaption {
  contain: inline-size;
}

That says: don’t factor in the figcaption in determining the intrinsic inline-size of the parent.

There was a way to do this before, as Temani Afif pointed out, with weirder trickery:

figcaption {
  inline-size: 0; /* or width */
  min-inline-size: 100%; */ or min-width */
}

Combined Demos

Video

While I was wrapping my mind around all this, I popped on a stream to do it. This isn’t like a straightforward tutorial, it’s the exploratory poking around and trying stuff that lead to my own better understanding (and the demos and this blog post).

]]>
https://frontendmasters.com/blog/the-figcaption-problem/feed/ 5 6532
Minimal CSS-only blurry image placeholders https://frontendmasters.com/blog/minimal-css-only-blurry-image-placeholders/ https://frontendmasters.com/blog/minimal-css-only-blurry-image-placeholders/#respond Mon, 14 Apr 2025 18:19:56 +0000 https://frontendmasters.com/blog/?p=5585 I’m not the world’s biggest fan of LQIP’s (low quality image placeholders) generally (doing nothing other than handling aspect ratio is… fine), but I really like how much creativity it inspires. I’ve seen a ton of different approaches to it over the years, that all use different technology and all have different advantages and disadvantages. Lean Rada has another interesting take with Minimal CSS-only blurry image placeholders where a single custom property is the seed for massively different CSS backgrounds generated from overlapping radial gradients with oklab colors.

]]>
https://frontendmasters.com/blog/minimal-css-only-blurry-image-placeholders/feed/ 0 5585
Comparing local large language models for alt-text generation https://frontendmasters.com/blog/comparing-local-large-language-models-for-alt-text-generation/ https://frontendmasters.com/blog/comparing-local-large-language-models-for-alt-text-generation/#respond Mon, 17 Mar 2025 17:21:14 +0000 https://frontendmasters.com/blog/?p=5415 Dries Buytaert:

I have 10,000 photos on my website. About 9,000 have no alt-text. I’m not proud of that, and it has bothered me for a long time.

Going back and hand-writing alt for 9,000 images isn’t a job that most of us can fit into our lives and I empathize. Are computers up for the task finally? Rather than pick a model and wire it up and do it, Dries wanted to do some testing and pick the best option. The answer isn’t perfectly clear, but there are some decent options and other forward thinking ideas here.

]]>
https://frontendmasters.com/blog/comparing-local-large-language-models-for-alt-text-generation/feed/ 0 5415
Movies as Images https://frontendmasters.com/blog/movies-as-images/ https://frontendmasters.com/blog/movies-as-images/#comments Wed, 05 Mar 2025 20:14:19 +0000 https://frontendmasters.com/blog/?p=5281 Animated GIFs just suck for web performance, so don’t put them on websites. To quote Colin Bendell:

Animated GIFs are terrible for web performance. They are HUGE in size, impact cellular data bills, require more CPU and memory, cause repaints, and are battery killers. 

So if you need that same behavior of automatically playing silent moving images, we’ve learned to do to use a <video> instead:

<video autoplay loop muted inline 
  src="https://assets.codepen.io/3/fire-ring.mp4">
</video>

That’s… attribute soup. But it works. Plus there is a very good chance the .mp4 is way smaller in file size than the GIF would be.

My vote would be to always include controls as another attribute on there. Yes it adds some UI over the image, but that UI allows the user to hit a stop button, which is an accessibility feature and one I use often to stop forever-playing anyway. If you don’t, at least wire up some CSS or JavaScript that stops the playing on click.

Since 2017, Safari has supported this alternate approach:

<img src="https://assets.codepen.io/3/fire-ring.mp4" alt="" />

Just an <img> tag! No attributes to remember and get right, and it has the exact same behavior. Except the fact that there is no way to pause it, which is a bummer.

There are various ways to ship MP4-as-img by falling back to other techniques. When I started writing this and testing things out I was all prepared to try those and be annoyed at non-Safari browsers for not supporting this idea. But I’ve changed my tune. The fact that the <video>-based technique works fine across browsers and has a clear path toward pausing the movement makes me feel like MP4-as-img is just a sub-part technique and probably shouldn’t be used at all.

Examples:

]]>
https://frontendmasters.com/blog/movies-as-images/feed/ 1 5281
Optimizing Images for Web Performance https://frontendmasters.com/blog/optimizing-images-for-web-performance/ https://frontendmasters.com/blog/optimizing-images-for-web-performance/#comments Mon, 10 Feb 2025 14:33:55 +0000 https://frontendmasters.com/blog/?p=5153 Images make websites look great, but they’re also the biggest performance bottleneck. They add huge file sizes, delay Largest Contentful Paint (LCP), and can even mess with Cumulative Layout Shift (CLS) if they aren’t handled properly. And while developers are quick to optimize JavaScript and CSS, images are often ignored—despite being the heaviest assets on most pages.

So, how do we make images fast, responsive, and efficient? Here’s how:

Choose the Right Image Format

The format of an image has a massive impact on its size and loading speed. Picking the wrong one can easily double or triple your image payload. Check out this illustration: 

Size comparison of images in formats

To the user, it’s the exact same image, but the browser has to download 2x-10x more data depending on the format you pick.

Have a look at this photograph: 

Size comparison of photo in formats

Photographs are quite a bit more complex than illustrations (usually), and the best formats can change. Notice how JPG is smaller than PNG this time, but modern formats like WebP and AVIF are still way smaller.

  • JPG – Best for photos with lots of colors and gradients. Uses lossy compression to keep file sizes small.
  • PNG – Best for graphics, logos, and transparency. Uses lossless compression, but files can be huge.
  • WebP – A modern format that’s often smaller than JPG and PNG while keeping quality high.
  • AVIF – Even better compression than WebP, but not universally supported yet.

good rule of thumbJPG for photos, PNG for graphics, and use WebP or AVIF where possible for modern browsers.

Use Responsive Images

Not all users have the same screen size, so why serve the same massive image to everyone? Responsive images let you deliver the right image size based on the user’s device, reducing unnecessary downloads and improving load times.

Instead of a single <img> tag, try using a <picture> with the srcset and sizes attributes to tell the browser which image to load:

In this example, any screen less than 1400px wide will use an image from the srcset that is at least 100% of the viewport’s width. So if the screen is 1100px wide, the browser will select and download the hero-desktop-1024 version. This automatically scales images to match different devices, saving bandwidth and improving loading speed for smaller screens.

Lazy-Load Below the Fold

One of the worst offenders for slow performance? Loading every image on the page at once—even ones that aren’t visible. This is where lazy-loading comes in. Adding loading="lazy" to an <img> prevents it from downloading until it’s about to be seen.

<img 
  src="downpage-callout.jpg" 
  loading="lazy" 
  height="300"
  width="300"
>  

It’s very important to specify the height and width attributes of images, especially if they are going to be lazy-loaded. Setting these dimensions let’s the browser reserve space in your layout and prevent layout shifts when the content loads. For more about layout shifts and how to prevent them, check out this deep dive on Cumulative Layout Shift.

For images that are critical for rendering, like your LCP element, you should override lazy-loading with fetchpriority="high". This tells the browser to load it ASAP.

<img 
  src="downpage-callout.jpg" 
  fetchpriority="high" 
  height="300" 
  width="300"
>

Serve Images from a CDN

Content Delivery Network (CDN) stores images in multiple locations worldwide, so they load from the nearest server instead of your origin. This speeds up delivery and reduces bandwidth costs.

CDNs use modern HTTP Protocols

Most CDNs will also speed up your images by serving them with modern protocols like HTTP/3, which has significant performance improvements over both HTTP/1 and HTTP/2. Check out this case study on HTTP/3 performance.

HTTP Caching headers

Users always have to download an image at least once, but HTTP caching headers can help repeat visitors load them much faster. HTTP Caching headers instruct the browser to hold onto the image and use it again, rather than asking for it from the CDN again on the next visit. Here’s an example:

Cache-Control: public, max-age=31536000, immutable

This tells the browser that this image won’t change, and that it can be kept locally for 1 year without needing to be requested again. Caching isn’t just for images—it speeds up all static assets. If you’re not sure if your caching is set up correctly, there’s a full guide on HTTP caching that explains how to check and optimize it.

Wrapping Up

Images are one of the biggest opportunities for improving performance. By choosing the right format, compressing efficiently, lazy-loading, and leveraging CDNs with modern protocols, you can massively speed up your site.

If you’re looking for more image optimization tips, with detailed breakdown and real-world examples, check out the complete guide to optimizing website images.

]]>
https://frontendmasters.com/blog/optimizing-images-for-web-performance/feed/ 6 5153
Keeping Pixely Images Pixely (and Performant!) https://frontendmasters.com/blog/keeping-pixely-images-pixely-and-performant/ https://frontendmasters.com/blog/keeping-pixely-images-pixely-and-performant/#respond Mon, 23 Sep 2024 22:17:49 +0000 https://frontendmasters.com/blog/?p=3972 After Marc chimed in with technique he needed to pull off as part of this years big promotion last week, it reminded me of another thing that ended up being relevant to the design aesthetic at play.

A horizontal slice of that background image was used here on this blog and the image size was quite tolerably small thanks to this CSS property.

See how everything has that 8-bit-ish pixely look to it? There is a CSS property that you can apply to images (<img> elements) that will affect how they look when they scale. Like if you’ve got an image like this:

You’re probably going to want to do this no matter what:

.castle-landscape-image {
  image-rendering: pixelated;
}

If there is any resizing at all any direction, it’s just going to look better. There is also image-rendering: crisp-edges; which is apparently specifically for pixel art, but I don’t see much of a difference.

Another cool thing we can do here is ensure the original art is as small as it can reasonably be (probably whatever size it was originally created at) and served like that, so any scaling beyond that doesn’t cause any anti-aliasing stuff (blurred edges) at all. In the case of the example above where I didn’t really have the original just a high-res version, I can scale it down and down trying to find the best place where it still looks fine but I’m saving lots of image space:

Another use-case here is something like a QR code. This QR code is 393 bytes (super small!). I’ll render it huge here and see how perfect it looks:

I have an SVG version of this same QR code that is 33 KB. This is a (very rare) case where a simple vector-looking graphic is actually better served from a binary image that the natively vector SVG.

Even a bit more extreme, here’s a 78 byte GIF (I hand-pixeled drew in Photoshop, and exported without metadata to get that small). It can scale up huge. Here’s the tiny natural one on top and the big one below:

Note that if you try the crisp-edges value on the above, it seems to bail out, so I guess there must be some kind of difference between the values.

Point of the story: if you’ve got a pixely <img>, chuck image-rendering: pixelated; and go as small as you can to save on size.

]]>
https://frontendmasters.com/blog/keeping-pixely-images-pixely-and-performant/feed/ 0 3972
On Alt https://frontendmasters.com/blog/on-alt/ https://frontendmasters.com/blog/on-alt/#respond Wed, 01 May 2024 14:36:50 +0000 https://frontendmasters.com/blog/?p=2014 Jake Archibald, by way of being convinced by Léonie Watson, says that the alt text of an of <img src="..." alt="..." width="..." height="..."> should evoke emotion:

The relevant parts of an image aren’t limited to the cold hard facts. Images can make you feel a particular way, and that’s something that should be made available to a screen reader user.

If necessary, that is. Some images just aren’t particularly emotional, like a screenshot of a banking dashboard. I think what Scott Vandehey says is a more broadly useful way to think about it:

Write alternative text as if you’re describing the image to a friend.

That’s helpful to me. Scott’s article has a bunch more practical advice and links about this topic from the community. Also good to remember: alt text shows when images are broken and different browsers handle that differently depending on how much of it there is.

]]>
https://frontendmasters.com/blog/on-alt/feed/ 0 2014
DOM to PNG Directly in the Browser https://frontendmasters.com/blog/dom-to-png-directly-in-the-browser/ https://frontendmasters.com/blog/dom-to-png-directly-in-the-browser/#comments Thu, 14 Mar 2024 01:51:48 +0000 https://frontendmasters.com/blog/?p=1232 You could design something on the web then take a screenshot of it. That is, in a basic sense, converting DOM to PNG. But a screenshot is rather manual and finicky. If you had to do this over and over, or you needed a very exact size (like a social media card), you can actually produce a PNG right from the browser with the click of a button. No backend service needed, like Puppeteer or Playwright which are often thought of for this type of automatic screenshotting job.

The trick is that you can render DOM to a <canvas>, thanks to the very fancy html2canvas library. Then you can use .toDataURL() to get it into PNG format and ultimately download it. I always reference Andrew Walpole’s Pen to see it done, which also happens to be a rather elegant Petite Vue example, and the contenteditable usage is a clever way to make it even more usable.

]]>
https://frontendmasters.com/blog/dom-to-png-directly-in-the-browser/feed/ 1 1232