Building an awesome image loading experience

October 19th, 2021 β€” 13 min read

frosted glass window
frosted glass window
No translations available.Add translation

You may have noticed when opening various pages on my site (especially blog posts) that the images start as blurry and then the full image fades-in once it's loaded. Here's a video to demonstrate that experience:

I want to walk you through some of the things I had to do for this to work, and compare some of what I'm doing to what the ultimate image app (unsplash.com) does.

Layout shift

First of all, you'll notice that the image doesn't pop into place and cause a reflow/layout shift. In fact, I've got a 100/100 on the "Cumulative Layout Shift" web vitals score 😊. I do this by specifying the size of the area that holds the image via the aspect-ratio plugin for tailwind (my site uses tailwind btw πŸ˜…).

TL;DR:

<div class="aspect-h-4 aspect-w-3 md:aspect-w-3 md:aspect-h-2">
  <img src="..." alt="..." class="..." />
</div>

That's all I need to make sure I don't get a bunch of layout shift while the image is loading (read more about this in Setting Height And Width On Images Is Important Again).

sizes, srcset, and Cloudinary

Another important aspect of making the image load fast is ensuring that you're only loading the size of image that you need. If you've got an image that's 3000x3000 and rendering that onto a retina screen in a 600x600 square, then you're serving 1800x1800 too many pixels! (retina means double-the pixels).

This is where the img tag's sizes and srcset attributes come to into play. The TL;DR of these attributes is that it allows you to tell the browser different versions of your image for different screen widths (srcset) and what size the image should be for a given set of media queries (sizes). Here's the example from MDN:

<img
  src="/files/16870/new-york-skyline-wide.jpg"
  srcset="
    /files/16870/new-york-skyline-wide.jpg 3724w,
    /files/16869/new-york-skyline-4by3.jpg 1961w,
    /files/16871/new-york-skyline-tall.jpg 1060w
  "
  sizes="((min-width: 50em) and (max-width: 60em)) 50em,
              ((min-width: 30em) and (max-width: 50em)) 30em,
              (max-width: 30em) 20em"
/>

What this says is that when the screen width is between 50em and 60em then the image will be 50em. So then the browser can determine the best image to load for that image size from the srcset you gave it. And look at that progressive enhancement! If the browser doesn't support these attributes it'll just use the src attribute like usual.

Unsplash uses this feature a great deal and so do I. But creating all those sizes of images would be an enormous pain, that's why I use cloudinary!

Here's what my img tag looks like for a blog post:

<img
  title="Photo by Kari Shea"
  class="z-10 rounded-lg object-cover object-center transition-opacity"
  alt="MacBook Pro on top of brown table"
  src="https://res.cloudinary.com/kentcdodds-com/image/upload/w_1517,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop"
  srcset="
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_280,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   280w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_560,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   560w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_840,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop   840w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_1100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 1100w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_1650,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 1650w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_2500,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 2500w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_2100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 2100w,
    https://res.cloudinary.com/kentcdodds-com/image/upload/w_3100,q_auto,f_auto,b_rgb:e6e9ee/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop 3100w
  "
  sizes="(max-width:1023px) 80vw, (min-width:1024px) and (max-width:1620px) 67vw, 1100px"
/>

And of course I don't write this out manually. I have a utility to generate these props for me:

function getImgProps(
  imageBuilder: ImageBuilder,
  {
    widths,
    sizes,
    transformations,
  }: {
    widths: Array<number>
    sizes: Array<string>
    transformations?: TransformerOption
  },
) {
  const averageSize = Math.ceil(widths.reduce((a, s) => a + s) / widths.length)

  return {
    alt: imageBuilder.alt,
    src: imageBuilder({
      quality: 'auto',
      format: 'auto',
      ...transformations,
      resize: {width: averageSize, ...transformations?.resize},
    }),
    srcSet: widths
      .map(width =>
        [
          imageBuilder({
            quality: 'auto',
            format: 'auto',
            ...transformations,
            resize: {width, ...transformations?.resize},
          }),
          `${width}w`,
        ].join(' '),
      )
      .join(', '),
    sizes: sizes.join(', '),
  }
}

Then I use it like so:

<img
  key={frontmatter.bannerCloudinaryId}
  title={frontmatter.bannerCredit}
  className="rounded-lg object-cover object-center"
  {...getImgProps(
    getImageBuilder(
      frontmatter.bannerCloudinaryId,
      getBannerAltProp(frontmatter),
    ),
    {
      widths: [280, 560, 840, 1100, 1650, 2500, 2100, 3100],
      sizes: [
        '(max-width:1023px) 80vw',
        '(min-width:1024px) and (max-width:1620px) 67vw',
        '1100px',
      ],
      transformations: {
        background: 'rgb:e6e9ee',
      },
    },
  )}
/>

We don't have too much time to get into the imageBuilder stuff. It's just a little abstraction I have on top of cloudinary-build-url for building cloudinary URLs in a typesafe way. My point is that Cloudinary makes it easy for me to serve you the right sized image for your device and screen size so it loads quickly and I save you data!

Unsplash's placeholder

If I stopped just at this point then users would just get a blank space before the image loads in. Much better to show some kind of placeholder. You've seen these around the web for sure. Medium was the first place I saw something like this. I used gatsby-plugin-sharp on the old version of this website which had support for an inline SVG that was a sort of tracing of the image (with mixed, but mostly positive results). And unsplash also has support for this. For this to work well, you need the placeholder to be smallish, server rendered, and inline. If you have to load your placeholder then you'll need a placeholder for your placeholder! As ridiculous as that sounds, this is actually what Unsplash does.

When you land on an unsplash image, there are three things that can happen in series depending on your network speed:

  1. The primary color of the image is displayed. This is server rendered.
  2. A blurred version of the image is displayed. I'm not sure whether they're using blurhash for this, but they're doing the exact same thing. It's a canvas drawing.
  3. The final image is loaded*

*There's a bit more to that step 3 which we'll talk about later.

These actually all happen, but they're layered with the image on top, then the blur canvas, then the div with a background color. That way unsplash shows the best thing it can as soon as possible. For this reason, it's quite possible you'll not see the blurred image on the initial page load, but if you navigate around after the page loads that's what you'll see for all other images and you won't see the primary color background anymore. This is because showing the blurred image canvas requires JavaScript to work.

So if the image loads before the JavaScript then the JavaScript won't have a chance to set up the canvas for the blurred image for you before you're looking at the actual image. And if the JavaScript is already loaded (like if you're doing client-side navigation) you won't see the background color and will only see the blurred image.

This is a nice setup and when I was working on my own image loading experience I looked at this for inspiration. The really cool thing about the blurhash / canvas approach is the size of the data required for the image is really small. Like, seriously, this is all you need to pass to the blurhash client library for a nice looking blur of a full image: LGFFaXYk^6#M@-5c,1J5@[or[Q6.:

blurhash website showing an image being
converted into a hash and the canvas representation of
that

Honestly, it's so cool. Magical πŸ§™

Ultimately the goal here is to minimize the amount of data needed to give the user a good experience while the full resolution image is loading. It's a balance of speed and a good user experience.

The kentcdodds.com image placeholder

When I was reverse engineering unsplash's image loading techniques, I evaluated whether I should just adopt their approach or try something a little different. I really didn't like the fact that they have to render a div with a solid background color before they can render the blurred image. Why not just render the blurred image on the server via a base64 encoded data URL?

So I decided to try it out. First I needed to find a way to automatically generate the base64 data URL. For one thing, I know that the URL would be very large if I just tried with the regular size image (this would basically negate all the user experience gains by making my page load slowly).

So I needed to generate a base64 data URL for a downscaled version of the image. This is really easy since I'm using Cloudinary for all my images. Additionally, cloudinary has the ability to apply transforms like blur on the image. This means that I could easily reduce the amount of data to represent in my base64 string. So I generate a cloudinary URL like this:

https://res.cloudinary.com/kentcdodds-com/image/upload/w_100,q_auto,f_webp,e_blur:1000/kentcdodds.com/content/blog/how-i-built-a-modern-website-in-2021/banner_iplhop

And when I fetch that image and base64 encode it, I end up with this:

data:image/webp;base64,UklGRhQBAABXRUJQVlA4IAgBAAAQDQCdASpkAEMAPrFGmko7qyWhsls9U3AWCWkGcA01nlwbK5buwWRoA3koD7+5vLBXAtOMrneG2GT90JyrLz+2XeotIAEq5PL4F0N1qTRIJ7LnMa5Zcre8UaDTMRtFt14eXNoGYkhNSt0REMN2PN4FwAD+7s4jHeyE9BXykzZMxIuwC4FSp408GYxRjoczsMvwZlqrnzr4cuA6X6MspvaoVHUro1XNU1SNxrLKLjhZrJ3GmlyoorlW1L532OP9tbhOeQgFiDwE81g+CH4d16xfOjEGrpus0wYxdunoI7Nokc5fnyoAw8pKJEq6cW3Yp4rqZw9fosV61qnAN+ViAH+WOzoqC6R90AA=

Now, that's quite a bit more than the 30 or so characters that represent the hash for blurhash, but remember that blurhash also requires a client library which is larger than this. But that's actually not the reason I didn't go with blurhash. Once you apply this to just a couple of images, blurhash more than pays for its own weight. So a site like unsplash definitely hits that point where it makes a lot of sense.

And blurhash would probably pay for itself on my site too. I have the recommendations at the bottom of every page which are all blur-loaded as well.

So why didn't I go with blurhash at this point? It's because I really didn't want to render the primary color. I just didn't think it looked good for my site. And this data URL wasn't so big that I thought it was worth server-rendering the solid block of color. It's a shame we can't server render a canvas. That'd be the best of both worlds. sigh

So I went with rendering the base64 behind the actual image. So while the image is loading I display a server-rendered blurred and upscaled version of the image.

Unfortunately, upscaling it like this made it look really pixelated.

Pixelated blog post placeholder

The platform to the rescue! I just slapped this in the DOM after my base64 image and we were in business:

<div class="backdrop-blur-xl"></div>

That ends up effectively applying this css:

backdrop-filter: blur(24px);

And we get the nice blur effect:

Blurred blog post placeholder

Sweet!

Fading in the image onload

I had a nice placeholder, but one thing that bothered me was when the image did load it would just appear in place of the placeholder, and I wanted it to feel like the placeholder kinda turned into the actual image. For this to work, I needed to write some JavaScript. I think it's about time I show you my BlurrableImage component... First, here's how I use it on my blog post page:

function BlogScreen() {
  // ...
  return (
    // ...
    <div className="col-span-full mt-10 lg:col-span-10 lg:col-start-2 lg:mt-16">
      {frontmatter.bannerCloudinaryId ? (
        <BlurrableImage
          key={frontmatter.bannerCloudinaryId}
          blurDataUrl={frontmatter.bannerBlurDataUrl}
          className="aspect-h-4 aspect-w-3 md:aspect-w-3 md:aspect-h-2"
          img={
            <img
              key={frontmatter.bannerCloudinaryId}
              title={frontmatter.bannerCredit}
              className="rounded-lg object-cover object-center"
              {...getImgProps(
                getImageBuilder(
                  frontmatter.bannerCloudinaryId,
                  frontmatter.bannerAlt ??
                    frontmatter.bannerCredit ??
                    frontmatter.title ??
                    'Post banner',
                ),
                {
                  widths: [280, 560, 840, 1100, 1650, 2500, 2100, 3100],
                  sizes: [
                    '(max-width:1023px) 80vw',
                    '(min-width:1024px) and (max-width:1620px) 67vw',
                    '1100px',
                  ],
                  transformations: {
                    background: 'rgb:e6e9ee',
                  },
                },
              )}
            />
          }
        />
      ) : null}
    </div>
    // ...
  )
  // ...
}

And here's the BlurrableImage component itself:

import * as React from 'react'
import clsx from 'clsx'
import {useSSRLayoutEffect} from '~/utils/misc'

function BlurrableImage({
  img,
  blurDataUrl,
  ...rest
}: {
  img: React.ReactElement<React.ImgHTMLAttributes<HTMLImageElement>>
  blurDataUrl?: string
} & React.HTMLAttributes<HTMLDivElement>) {
  const [visible, setVisible] = React.useState(false)
  const jsImgElRef = React.useRef<HTMLImageElement>(null)

  React.useEffect(() => {
    if (!jsImgElRef.current) return
    if (jsImgElRef.current.complete) return

    let current = true
    jsImgElRef.current.addEventListener('load', () => {
      if (!jsImgElRef.current || !current) return
      setTimeout(() => {
        setVisible(true)
      }, 0)
    })

    return () => {
      current = false
    }
  }, [])

  const jsImgEl = React.cloneElement(img, {
    // @ts-expect-error no idea πŸ€·β€β™‚οΈ
    ref: jsImgElRef,
    className: clsx(img.props.className, 'transition-opacity', {
      'opacity-0': !visible,
    }),
  })

  return (
    <div {...rest}>
      {blurDataUrl ? (
        <>
          <img
            src={blurDataUrl}
            className={img.props.className}
            alt={img.props.alt}
          />
          <div className={clsx(img.props.className, 'backdrop-blur-xl')} />
        </>
      ) : null}
      {jsImgEl}
      <noscript>{img}</noscript>
    </div>
  )
}

export {BlurrableImage}

Alright, that's a bit to take in... Let me walk you through it...

First, the props are pretty simple. We accept an img element which is the ultimate image we want to be loaded. We accept a blurDataUrl to render a blurred version of the image while we're waiting for the image to load. And then the rest of the props are just applied to the div that's the container for everything. I pretty much only use that for the className for the aspect ratio stuff.

Let's skip all the stuff in the middle and go down to what we're rendering:

We render a wrapper div to keep everything together (in particular for the aspect ratio stuff to work properly).

Then if there is a blurDataUrl, we render an img element with the blurDataUrl. We inherit the className to ensure we get stuff like the right border radius etc.

Then under that we render the backdrop to smooth out the blurriness of the data URL image since that's going to be scaled up as described earlier.

Then we render what I call a jsImgEl. This is a copy of the img. The jsImgEl is the primary image that will be displayed to the user when all is said and done. I make a copy of it so I can add some css for the fade-in action. More on this in a moment.

Finally, the <noscript>{img}</noscript> stuff is there for the handful of users who might disable JavaScript because otherwise they'll never get the image displayed (because displaying the image requires JavaScript). There probably aren't many (any) users like this, but it's just so easy so why not?

Alrighty, so to make the fade work, we need to have the jsImgEl start out as invisible. The browser will still load this for us though, and it fires events along the way, so we use useEffect to add an event handler to know when it's loaded and when it does finish loading we'll trigger an update to make the image fade in.

And really that's it.

Conclusion

So in review, to make an excellent image loading experience I'm doing a few things:

  1. Avoid layout shift by using Tailwind's aspect-ratio plugin
  2. Load the perfect sized image via the <img /> attributes sizes and srcset + cloudinary transforms.
  3. Generate the base64 encoding of a smaller blurred version of the image (shoutout to cloudinary). I do cache this to make it fast.
  4. Render the blurred image inline so it can be server rendered along with some JS to load the full image and display it when it's finished loading.

And that's what powers the awesome image loading experience on this site. It's important to remember that user experience is not all about performance. It's also about the experience. And I feel like the trade-offs I've made here are solid for the user experience I want to provide you as you navigate this site. I hope you love it :)

Kent C. Dodds
Written by Kent C. Dodds

Kent C. Dodds is a JavaScript software engineer and teacher. He's Co-Founder and Director of Developer Experience at Remix! Kent's taught hundreds of thousands of people how to make the world a better place with quality software development tools and practices. He lives with his wife and four kids in Utah.

Learn more about Kent

Want to learn more?

Join Kent in a live workshop

If you found this article helpful.

You will love these ones as well.