Simpler responsive images in Umbraco

15. september 2022

Why?

The average web page is nearing 5 Mb, and about 70% of web traffic is images.

Creating responsive images that with image files that match the layout of your page brings several benefits:

  • Your page loads faster, giving your users a better experience
  • The negative climate impact of your page is reduced
  • Better page metrics with Google has a positive SEO impact.
  • Images cropped to match device sizes looks and communicates better.

But creating modern optimised and responsive images is cumbersome and requires a lot more consideration than a simple <img> tag.

I have worked with Umbraco for about a decade. Let me show you my approach.

I'll show you how I create responsive images, and my thoughts behind this method.

I'm also going the share the backend code I use, but not dig into every detail. Grab it, use it, modify it to your needs, and above all else, let me know how you would improve it.

See the code

 

Why we need automation

If you don't know about the html of creating responsive images, I suggest you take a look at this article, called Responsive Images 101.

One of the conclusions from this article is: Humans shouldn't be doing this. I wholeheartedly agree. Creating good responsive images require some level of automation to be practical, and this goes for both the editor of the site and you as a developer.

Requiring the editor to upload several versions of the same image is bound to cause problems. Resizing, cropping, exporting maybe 5-10 image file per image is too much work. Force your editors to do this and corners will be cut and quality will suffer. Instead, make them upload a high resolution source image and automatically create the size that matches your layout.

The same goes for you as a developer. Hand crafting a large <picture> tag is hard and takes time. Ideally, configuration should be so simple, that you would use it over the simple <img> tag, even when you are busy or lazy - I’m often at least one of the two.

The Goal

My goal is to make it as easy as possible to quickly create a well configured responsive image in Umbraco with the following requirements:

  1. The dimensions of the image file must closely match the size the image is displayed in, so the file size is kept to a minimum.
  2. I want to use modern file formats like webp or avif with a jpeg fallback for older browsers.
  3. Support for high definition images for high definition screens.
  4. It must be possible to crop images to a give aspect ratio depending on the width of the screen. I case we want a square-ish top image on a phone and a letterboxed one on a large desktop screen.

My Solution

My solution is a simple Fluent API that configures how to display the image for a set of breakpoints, and renders a <picture> tag with these breakpoints and a range of image files in different sizes for each breakpoint. I’ve opted for a fluent api because I find it makes the configuration easy to understand and adjust if the layout changes along the way.

I make use of ImageSharp included in Umbraco, to dynamically create resized and cropped versions of images, as well as format conversions. If you haven’t used it yet, you’ll love it. Resizing an image is as simple as adding a query string to the image URL.

 

An example

So this is how it works.

Say you want an image that is displayed in the site's full width on narrow screens, displayed in half width on medium screens, and on large screens is displayed in half width of the site while the site is constrained to a fixed max-width. It’s a very common configuration.

A preview of the image configuration described on three devices

To create this in a template you would go:

@Model.Image.RenderPicture(cfg => cfg
	.SizeBelowVw(900, 100)
	.SizeBelowVw(1200, 50)
	.SizeAbovePx(600)
)

This creates an image with 3 breakpoints:

  1. Below a viewport width of 900px, the image is displayed in 100% of the viewport width
  2. Between 900px and 1200px the image is displayed in 50% of the viewport width
  3. Above 1200px the image is displayed at 600px

The minimum width of breakpoints 2 is implicitly set by the maximum width of breakpoint 1. Same goes for breakpoints 2 and 3.

I can crop images by adding an crop aspect ratio to one or more of the breakpoints, like so:

@Model.Image.RenderPicture(cfg => cfg
	.SizeBelowVw(900, 100, "5:3")
	.SizeBelowVw(1200, 50, "5:4")
	.SizeAbovePx(600, "5:4")
)

And, of course you need to add an alt text, and probably some other attributes to the <img> tag:

@Model.Image.RenderPicture(cfg => cfg
	.SetAttribute("alt", Model.AltText)
	.SetAttribute("class", "responsive-image")
	.SizeBelowVw(900, 100, "5:3")
	.SizeBelowVw(1200, 50, "5:4")
	.SizeAbovePx(600, "5:4")
)

The helper creates a <picture> tag similar to:

<picture>
	<source media="(max-width:900px)" sizes="(max-width:900px) 100vw" srcset="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1800&amp;height=1080&amp;quality=80&amp;format=webp 1800w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1440&amp;height=864&amp;quality=80&amp;format=webp 1440w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1152&amp;height=691&amp;quality=80&amp;format=webp 1152w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=921&amp;height=552&amp;quality=80&amp;format=webp 921w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=736&amp;height=441&amp;quality=80&amp;format=webp 736w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=588&amp;height=352&amp;quality=80&amp;format=webp 588w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=470&amp;height=282&amp;quality=80&amp;format=webp 470w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=376&amp;height=225&amp;quality=80&amp;format=webp 376w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=300&amp;height=180&amp;quality=80&amp;format=webp 300w" type="image/webp">
	<source media="(max-width:900px)" sizes="(max-width:900px) 100vw" srcset="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1800&amp;height=1080&amp;quality=80&amp;format=webp 1800w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1440&amp;height=864&amp;quality=80&amp;format=webp 1440w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1152&amp;height=691&amp;quality=80&amp;format=webp 1152w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=921&amp;height=552&amp;quality=80&amp;format=webp 921w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=736&amp;height=441&amp;quality=80&amp;format=webp 736w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=588&amp;height=352&amp;quality=80&amp;format=webp 588w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=470&amp;height=282&amp;quality=80&amp;format=webp 470w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=376&amp;height=225&amp;quality=80&amp;format=webp 376w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=300&amp;height=180&amp;quality=80&amp;format=webp 300w" type="image/jpeg">
	<source media="(min-width:901px) and (max-width:1200px)" sizes="(min-width:901px) and (max-width:1200px) 50vw" srcset="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1200&amp;height=960&amp;quality=80&amp;format=webp 1200w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=960&amp;height=768&amp;quality=80&amp;format=webp 960w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=768&amp;height=614&amp;quality=80&amp;format=webp 768w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=614&amp;height=491&amp;quality=80&amp;format=webp 614w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=491&amp;height=392&amp;quality=80&amp;format=webp 491w" type="image/webp">
	<source media="(min-width:901px) and (max-width:1200px)" sizes="(min-width:901px) and (max-width:1200px) 50vw" srcset="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1200&amp;height=960&amp;quality=80&amp;format=webp 1200w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=960&amp;height=768&amp;quality=80&amp;format=webp 960w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=768&amp;height=614&amp;quality=80&amp;format=webp 768w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=614&amp;height=491&amp;quality=80&amp;format=webp 614w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=491&amp;height=392&amp;quality=80&amp;format=webp 491w" type="image/jpeg">
	<source media="(min-width:1201px)" sizes="(min-width:1201px) 100vw" srcset="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1200&amp;height=960&amp;quality=80&amp;format=webp 1200w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=960&amp;height=768&amp;quality=80&amp;format=webp 960w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=768&amp;height=614&amp;quality=80&amp;format=webp 768w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=614&amp;height=491&amp;quality=80&amp;format=webp 614w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=491&amp;height=392&amp;quality=80&amp;format=webp 491w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=392&amp;height=313&amp;quality=80&amp;format=webp 392w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=313&amp;height=250&amp;quality=80&amp;format=webp 313w" type="image/webp">
	<source media="(min-width:1201px)" sizes="(min-width:1201px) 100vw" srcset="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1200&amp;height=960&amp;quality=80&amp;format=webp 1200w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=960&amp;height=768&amp;quality=80&amp;format=webp 960w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=768&amp;height=614&amp;quality=80&amp;format=webp 768w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=614&amp;height=491&amp;quality=80&amp;format=webp 614w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=491&amp;height=392&amp;quality=80&amp;format=webp 491w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=392&amp;height=313&amp;quality=80&amp;format=webp 392w,/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=313&amp;height=250&amp;quality=80&amp;format=webp 313w" type="image/jpeg">
	<img alt="Simon" src="/media/r5jjd5nh/simon-bw.jpg?rxy=0.5509641873278237,0.43309300917334104&amp;width=1000&amp;quality=80&amp;format=jpg">
</picture>

So that’s a lot of HTML! Good thing we didnt need to write that by hand.

Behind the curtain

You can go check out my code, but let me explain in principle what it does.

  1. It figures out the breakpoints based on your input. For the example above it will create a breakpoint with (max-width: 900px), a middle breakpoint with (min-width:901px) and (max-width:1200px) and a large breakpoint with (min-width:1201px). As you see the min-width of the breakpoint is implicitly figured out based on the max-width you have provided, so we don’t have to provide the obvious min-width values of 901 and 1201.
  2. It calculates the ranges of possible display widths of each breakpoint, because we want to create image files that matches those. Take the middle breakpoint: it’s between a viewport width of 900px and 1200px and the image will be displayed at 50vw, so the possible range is 450px to 600px. With support for 2x displays the max width will be 450-1200px.
  3. Now it renders the picture tag with two source tags for each breakpoint - one for the webp version, one for the jpeg. Each source tag has a range of urls to images in different widths that matches the possible display sizes of the image.

When automating like this it can be tempting to just throw lots of files at the browser: the closer you match the display width of the image with the pixel width of the image file, the more data you save. However, you need to be aware of the size of the picture tag in the html, it can quickly grow to 5-10kb with many image sources, and you don’t want the data you save on the image files to be offset by a huge chunk of html.

I have decided to create image files that start with the largest possible image size and go down to the smallest in steps of 20%, stopping at 300px.

For the range 450px to 1200px I get:

  • 1200px
  • 960px
  • 768px
  • 614px
  • 491px

For the range 0 to 1800px I get:

  • 1800px
  • 1440px
  • 1152px
  • 921px
  • 736px
  • 558px
  • 470px
  • 376px
  • 300px

If you’ve set the aspect ratio option I also crop the heights if the image, using the focus point that you can set in Umbracos media library.

Possible improvements

  • The distribution of images sizes could be better. I go down from the largest width in steps of 20%. But ideally you'd have smaller steps with wider images, because they are also often higher. The ideal step size is possibly closer to X% of the total pixel count of the image.
  • I create a seperate sources tags for each breakpoint, to ensure that I can specify different crops for each breakpoint. If we use the image's intrinsic aspect ratio (and no breakpoints have aspect ratio defined) the picture tag could be simplified by only having two sources tag for the jpeg and webp versions, by adding all breakpoints to the "sizes" attribute. This would save a few kb of html per image.
  • Our picture tag gets really large, 5-10 kb. If we could create some kind of url shortening for the individual image files, we could make the picture tag smaller, and maybe in turn maybe increase the amounts of files used to better match the current display size.
  • I default to lazy loading using loading="lazy" attribute. Best practice is to only lazy load images below the fold.
  • AVIF support is on my wish list - browser support is around 70%, so it's usable as long as we have a widely supported fallback. ImageSharp doesn't support this out of the box currently.

Conclusion

This is my approach for creating responsive images with a simple configuration in Umbraco. You can do this in a number of different ways, and i'm not here to argue that my solution is the best and final solution to this problem.

But I will argue that the picture tags for responsive images shouldn't be coded by hand. It's to much manual work and in practise that means that corners will be cut. Automate as much as possible and make it easy on yourself and your editors instead. That ensures you get a consistent good quality.

See the code

About this blog

I usually write in Danish about sustainable web development, privacy and other issues related to ethics and web development.

I write this in English, because I thinks it's relevant for the Umbraco community.

If you want to keep up with similar posts in english, find me here: