Rendering performant Sanity.io images

How I've been approaching rendering Sanity images in the web for the past years

Non-web developers are surprised when we tell them how complicated rendering images can be. Worrying about the layout is only a part of it, we also need to ensure images are accessible, performant, and responsive.

Over the years, I've spent hours obsessing over details and often got overwhelmed. Thankfully, Sanity.io's powerful CDN and recent CSS & HTML advancements made it much simpler & easier to get it right. Let's get to it!

The holy grail of web images

In short, we want crisp, fast-loading, accessible images that don't waste bandwidth. Concretely, they need to:

  1. Scale to their visual placement on the page - no 3000px-wide file in a tiny 50px avatar component
  2. Scale to the user's device pixel density - a 3x retina screen will use 150 actual pixels to render 50 "virtual" pixels (the equivalent of CSS's 50px)
  3. Be served in the most effective format - if a simple vector, an SVG will always be smaller & crispier, for example
  4. If cropped by the layout, they should adapt to their container with proper focus on the most important bits of the picture
  5. Only load when in view (lazy loading)
  6. Contain clear descriptions in the alt property
  7. Avoid low contrast and other visibility issues
Going deeper

MDN's responsive images guide is a great read if you want to diver deeper into the points above. It goes beyond the technicalities of srcsets and brushes on art direction, which is often required for specific types of content.

With Sanity's image CDN we can automate 1, 2 & 3 and give editors the peace of mind of images just workingβ„’, so they can focus on creating high-quality content and not on scaling files.

For cropping (#4), you can enable hotspots & crops in Sanity and have your front-end(s) react automagically to them, ensuring the most important bits of the image are always shown, as defined by creators.

6 & 7 are out of the front-end's control, but you can instruct and nudge your editors to help visually impaired readers. See step 3 below.

Now, let's go through the step-by-step to achieve the holy grail above 🌟

Step 1: generating image variations

Sanity's CDN allows you to generate multiple variations of the same image by simply replacing a few query parameters in their URLs, such as:

  • ?w=200 (scale to 200px wide)
  • ?fit=max (scale the image up to its maximum dimensions)
  • ?auto=format (use whatever format is more efficient)
  • ?bg=333 (add a #333 gray color as the image's background, if transparent)
  • ?q=40 (compress the image to 40% of its original quality)

Refer to the image URL documentation for all the available transformations.

Try copying the URL of this image and experimenting with its query parameters πŸ˜‰

Knowing this, we can build as many size variations of an image as needed for achieving the holy grail described above ✨

We'll generate a set of image sources to feed into the img element's srcset, and specify a sizes property with how the image scales according to the window size, and let the browser pick which image to download based on the user's device width and pixel density. Here's how I generate these variations:

import imageUrlBuilder from "@sanity/image-url";

export const imageBuilder = imageUrlBuilder({
  projectId: "SANITY_PROJECT_ID",
  dataset: "SANITY_DATASET",
});

/**
 * Example `image`: {asset: {_ref: string}, hotspot: {...}, crop: {...} }
 */
export default function getImageProps({ image, maxWidth = 1200 }) {
  if (!image?.asset?._ref) {
    return {};
  }

  // For all image variations, we'll use an auto format and prevent scaling it over its max dimensions
  const builder = imageBuilder.image(image).fit("max").auto("format");

  // Arbitrary sizes the image could assume
  const baseSizes = [400, 600, 850, 1000, 1150, maxWidth];
  const retinaSizes = Array.from(
    new Set([
      ...baseSizes,
      ...baseSizes.map((size) => size * 2),
      ...baseSizes.map((size) => size * 3),
    ])
  )
    // Smallest to largest
    .sort()
    .filter(
      (size) =>
        // Exclude those larger than maxWidth's retina (x3)
        size <= maxWidth * 3
    )
    // Exclude those with steps smaller than 50px relative to their following size
    .filter((size, i, arr) => {
      const nextSize = arr[i + 1];
      if (nextSize) {
        return Math.abs(nextSize - size) > 50;
      }

      return true;
    });

  return {
    // Use the original image as the `src` for the <img>
    src: builder.width(maxWidth).url(),

    // Build a `{URL} {SIZE}w, ...` string for the srcset
    srcset: retinaSizes
      .map((size) => `${builder.width(size).url()} ${size}w`)
      .join(", "),
    sizes: `(max-width: ${maxWidth}px) 100vw, ${
      // Ensure browsers use best quality by suggesting the image is slightly larger than it actually is
      maxWidth + 20
    }px`,
  };
}

Note

If the usage of imageBuilder isn't clear, refer to @sanity/image-url's documentation.

Then, we can use getImageProps with any image, according to its maximum size in the layout:

// We call getImageProps with the raw `image` object, not the expanded asset reference.
getImageProps({
  image: {
    _key: "389dd32f5e44",
    _type: "image",
    alt: "A browser window showing another image of this website",
    asset: {
      _ref: "image-556d99f9e1be990a12c0742c62008fe3147f4bad-1430x626-png",
      _type: "reference",
    },
    crop: { /* ... */ },
  },
  maxWidth: 600
});

This is the bare minimum, but you could also extend the function above to consider a maximum height, or deal with fixed image sizes. I used to have a more complicated set-up, but this one works for +90% of all images and I'm happy with it.

Step 2: rendering the image

With the srcset, src and size values ready, we can start building the image component itself. I'll use Svelte as it's close to HTML, but you can use these principles with any web framework. Let's start with the basics:

<!-- SanityImage.svelte -->
<script>
	import getImageProps from '../utils/getImageProps';

	export let image;
</script>

<img
  alt={image.alt || " "}
  {// Pass src, srcset and sizes to the image element
    ...getImageProps({
      image,
      maxWidth: 600
    })
  }
/>

With the above, images will load and the browser will choose the proper size for them. BUT all images are loading as soon as the page loads, and what we really want is to only load them when they show up on-screen - the famous lazy loading.

In the past, I'd leverage custom lazy loading code that would hide the image until an IntersectionObserver fired when its parent showed up in the viewport. Then, I'd finally add the <img> element to the DOM and the browser would do its magic.

It worked, but it has a few accessibility and SEO concerns, as the content isn't all there for non-visual readers & bots. Thankfully, in 2022 the loading="lazy" attribute (for images, not iframes) works on all major browsers. So, with one line our images are lazily loaded ✨

<img
  loading="lazy"
  alt={image.alt || " "}
  {// Pass src, srcset and sizes to the image element
    ...getImageProps({
      image,
      maxWidth: 600
    })
  }
/>

The above won't be 100% smooth for UX, though, as the browser won't know what height the image will assume at a given width. This leads to content jumping around as images load and the browser recalculates their dimensions and the page's layout.

Note

I won't get into any other image styles here - go wild with your designs, it's a simple img element you can plug anywhere! πŸ”₯

In the past, I'd use the padding hack to force a consistent height on the image element (or its parent) and hence prevent content from jumping around. It worked wonderfully but led to extra markup and styles that made it harder to create complex image layouts. Plus, the image component ended up much harder to reason about.

Again, it's 2022 and the aspect-ratio CSS property is now supported across all major browsers - let's fix this in a single CSS declaration!

<img
  style="aspect-ratio: ${getImageAspectRatio(image)};"
  loading="lazy"
  alt={image.alt || " "}
  {// Pass src, srcset and sizes to the image element
    ...getImageProps({
      image,
      maxWidth: 600
    })
  }
/>

And, to getImageAspectRatio, we can rely on its _id (asset._ref):

function getImageAspectRatio(image) {
  if (!image?.asset?._ref) {
    return 0;
  }

  // example asset._ref:
  //  - image-7558c4a4d73dac0398c18b7fa2c69825882e6210-366x96-png
  // When splitting by '-' we can extract ["image", _id, dimensions, extension]
  const dimensions = image.asset._ref.split("-")[2];
  // "366x96" -> ["366", "96"] -> [366, 96]
  const [width, height] = dimensions.split("x").map(Number);
  return width / height;
}

As an added bonus, our component is now much less tied to framework-specific features. It's literally a couple of generic JS functions and a single <img /> element with dynamic attributes.

React version:

const SanityImage = ({ image }) => {
  return (
    <img
      style={{ aspectRatio: getImageAspectRatio(image) }}
      loading="lazy"
      alt={image.alt || " "}
      {// Pass src, srcset and sizes to the image element
        ...getImageProps({
          image,
          maxWidth: 600
        })
      }
    />
  )
}

Plain JS version:

const SanityImage = ({ image }) => {
  const { src, srcset, sizes } = getImageProps({
    image,
    maxWidth: 600
  });
  return `
    <img
      style="aspectRatio: ${getImageAspectRatio(image)}"
      loading="lazy"
      alt="${image.alt || " "}"
      src="${src}"
      srcset="${srcset}"
      sizes="${sizes}"
    />
  `
}

Above-the-fold, "eager" image loading

For images above the fold, such as the one in the "hero" (or header) component, you don't want to lazy load them as you want users to see them as soon as the page is loaded. The image component above could easily support this modification with a configurable loading property:

<!-- SanityImage.svelte -->
<script>
	import getImageProps from '../utils/getImageProps';

	export let image;
	export let loading = "lazy"
</script>

<img
  style="aspect-ratio: ${getImageAspectRatio(image)};"
  {loading}
  fetchPriority={loading === "eager" ? "high" : undefined}
  alt={image.alt || " "}
  {// Pass src, srcset and sizes to the image element
    ...getImageProps({
      image,
      maxWidth: 600
    })
  }
/>

<!-- Using the above: -->
<SanityImage image={...} loading="eager" />

Notice we're adding fetchPriority="high" for eager images, which tells the browser to give preference to loading them sooner, reducing the hit on the Largest Contentful Paint performance benchmark.

Bonus: image loading transition/animation

For a nicer loading effect, we can hide the image until it's fully loaded, and then trigger an opacity transition for a subtle-yet-elegant animation. Here's how I achieve that in Svelte:

<script>
  // ...
  
  let loaded = false
</script>

<img
  ...
  data-loaded={loaded}
  on:load={() => loaded = true}
/>

<style>
  img {
    transition: .15s opacity;
  }
  img[data-loaded="false"] {
    opacity: 0;
  }
</style>

Once loaded, the browser will call the onload event, and the data-loaded attribute will become true, changing the CSS from opacity: 0 to the unset, default value of 1, creating the simple transition.

Unknown block type "video", specify a component for it in the `components.types` prop

Step 3: guiding editors & enforcing accessibility

Finally, a crisp, fast-loading, and frugal image component is no good if its output is not shared across all users. To make it more democratic, guide your editors to supplement images with useful alternative descriptions. You can even make the alt fields required if your team is open to it!

// Example image schema in Sanity
export default {
  name: "image",
  title: "Image / photo",
  description:
    "πŸ’‘ highest quality possible without upscaling the image (up to 2500px). If the image contains text, make sure it's highly readable with a high contrast.",
  type: "image",
  options: { hotspot: true },
  fields: [
    {
      name: "alt",
      title: "Accessibility label for the image",
      description:
        'Help make the site more accessible & SEO-friendly with a short textual description of the image, such as "screenshot of the dashboard app"',
      type: "string",
      validation: Rule => Rule.required(),
      options: {
        isHighlighted: true,
      },
    },
  ],
};

If you want to go the extra (10) mile(s), you could test an image's contrast and how friendly to color-blind people it is. I'm not sure if there's a clear way to run these tests automatically, but given Facebook has been running checks on text in ads' images for almost 10 years, I'm sure we can figure it out. I'd love to help implement something like this, reach out if you're interested: meet@hdoro.dev or hdorodev


That's it! Get in touch if you have questions or suggestions for how to further improve this image component, it'll be a pleasure to connect :)

Changelog

Configure the image builder without @sanity/client

June 21, 2022

Kitty Giraudel noticed that I was importing the full @sanity/client in front-ends just to configure @sanity/image-url's builder, exposing 67kb of extra javascript to the user's browser.

By configuring imageUrlBuilder with just the projectId and dataset, we remove the need to import the client.

Added section on eager images

June 21, 2022

Thomas Kim pointed me to this image optimization article on Builder.io which refers to the fetchPriority and decoding img attributes. I don't personally think adding a low fetch priority and asynchronous decoding to lazy loaded images makes sense, as we want to load them ASAP once they're onscreen.

BUT this article reminded me I should have a solution for above-the-fold, "eager" images. Hence this new section.

Added a section on loading transition

June 21, 2022

I realized I forgot to add a loading transition for a smoother UX lazy images. Now fixed :)