Responsive Images at Casper

As a software engineer on Casper’s Shop team, my focus over the past few months has been building out React components that can be populated with data from our new CMS. During this time, I got a crash course in how we optimize image loading at Casper. In this post, I’ll break that process down for others who are interested in optimizing images on their sites. For context, here’s the component I built, the ContentModule:

ContentModule responsiveness, from mobile to desktop. As seen on https://casper.com/bedding/

The product team designed this module to have two different image crops, one for mobile and one for desktop. Some modules on our site also have a specific crop for tablets, so it’s important for us to be able to optimize images across all devices. In my first pass at this module, I was rendering images that were much bigger than they needed to be, which adds a risk of increased page load times.

An important part of optimizing image sizes is thepicture element. MDN has a great overview of the picture element:

The HTML <picture> element serves as a container for zero or more <source>elements and one <img> element to provide versions of an image for different display device scenarios. The browser will consider each of the child <source> elements and select one corresponding to the best match found; if no matches are found among the <source> elements, the file specified by the <img> element's src attribute is selected. The selected image is then presented in the space occupied by the <img> element.

To visualize this, here’s an example picture element from MDN:

<picture>
<source
srcset="/media/cc0-images/Wave_and_Surfer — 240x200.jpg"
media="(min-width: 1000px)"
>
<img src="/media/cc0-images/Painted_Hand — 298x332.jpg">
</picture>

In this case, if your browser is at least 1000px wide, you can see an image of a surfer with one set of dimensions. If it’s 999px wide or below, you see a different image with different dimensions.

We use similar logic to display images of different crops and sizes at different browser breakpoints. We incorporate this logic into React components.

In our component library, we use a component calledResponsiveImage to render the picture element. Here’s what the picture element being returned by ResponsiveImage looks like:

<picture>
{sources}
<img
alt={props.description}
className={props.className}
onLoad={props.handleOnLoad}
src={src(lastItem.path, lastItemWidth, lastItemQuality)}
/>
</picture>

We can ignore the img element for now, since that’s basically a fallback. Here’s what’s happening in {sources} :

const sources = props.config.map((source, idx) => {
const { media, min, max, path, sizes, quality } = source;

return (
<source
key={idx}
media={media}
sizes={sizes}
srcSet={srcset(path, min, max, quality)}
/>
);
});

Let’s ignore props for now and compare this source element to the picture element we have from MDN. They both have srcset and media attributes. This source element also has an optionalsizes attribute and the requisite key for a React item in a list. (Side note: We are using an antipattern, but since it’s only used on images that won’t be deleted or moved around, it shouldn’t make anything explode.)

Based on what we know about how the picture element works, every time the page either loads for the first time or the browser width changes, the picture element should check each source element to see if one is a match. If it finds one, it uses it. If it doesn’t, it uses the img fallback.

As you can see in the code above, the values for srcSet, sizes, andmedia all come from each object inprops.config. Let’s trace how this prop is passed using the ContentModule (shown at the beginning of this post).

Each image in this module is a ResponsiveImage. We define the config in the ContentModule component and pass it to the ResponsiveImage. Here’s one of the image configs we use for the ContentModule:

const contentImageConfig = [
{
path: imagePathMobile,
media: `(max-width: 1023px)`,
sizes: `(min-width: 768px) 43vw,
(min-width: 560px) 88vw, 81vw`,
min: 256,
max: 675,
},
{
path: imagePathDesktop,
media: `(min-width: 1024px)`,
sizes: `(min-width: 1488px) 608px, 41vw`,
min: 424,
max: 608,
},
];

You may have noticed that we’re not specifying a value for quality here. In our srcSet util, quality is optional, and we have a default quality of 65 set.

Your image config can consist of either an array of objects or a single object. Each object represents a different image crop. In our example of thecontentImageConfig, we have two crops, one for mobile and one for desktop, where the paths for the crops (imagePathMobile and imagePathDesktop) are data values passed in from our CMS.

If the image crop does not change across devices, you only need a single config object, and you can omit the media key. For example:

const imageConfig = {
path: `products/platform_bed.jpg`,
sizes: `(min-width: 1600px) 736px, (min-width: 1024px) 46vw, (min-width: 768px) 464px, 100vw`,
min: 560,
max: 736,
};

When the image config is an array, the ResponsiveImage component renders a picture element. When the image config is a single object, it renders an img element.

Next we have the sizes key. This key is read from right to left, in increasing order of screen size. The value furthest right with no screen size is the default value for this config object. So in the case of the first object, which has the mobile image path ("products_new/nap-pillow/test-mobile-single.jpg"), the default width of the image is 81vw. Then starting at a width of 560px, the image has a width of 88vw. The min and max keys are pixel values for images in this range (determined by the max-width value in the media key).

Now that we know how to read an image config, how do we actually write one? We need to take into account all breakpoints. In the component library where this code lives, we have five sets of breakpoints: mobile portrait, mobile landscape, tablet portrait, tablet landscape, and larger screens. Looking again at the first object in the imageConfig, let's first determine the width of the image at the smallest screen size, the low end of our mobile portrait set of breakpoints, 320px:

The image width is 256px. In this case, the image gets bigger as the screen gets wider, so we can set this as the minvalue. (In some cases, the image might get smaller as the screen gets larger. This means we can't always set the min to the width of the image at the low end of the range.) Now we can get the default value in vw, or the rightmost value in the sizes key, by dividing the image width by the screen width: 256 / 320 = 0.8.

So why is the default set to 81vw? You’ll notice we don’t update the vw again until we hit a min-width of 560px. Before that point, the width of the image only changes slightly. At 504px, the width of the image is 408px. 408 / 504 = 0.81. This is a small enough difference that we don’t need to create a separate rule for it in the sizes key, and can just tell our smallest screen size to render a slightly larger image than it needs.

After mobile portrait, our next set of breakpoints is mobile landscape (560px to 767px). To get vw for the minimum width of 560px, check various sizes between 560px and 767px to determine the largest percentage of the screen the image takes up. In this case, it was 0.88, for 88vw. Next, let’s figure out the max value. Instead of just writing the maximum pixel amount, multiply the highest vw amount in the sizes key by the maximum screen size. In this case, the maximum screen size is 767px.

There are a few caveats. In some cases, the image may just stop growing after a certain pixel value. This happens for some images at the maximum screen width. Here’s an example of what that config might look like:

{
path: `products_new/nap-pillow/test-mobile-single.jpg`,
media: `(min-width: 1024px)`,
sizes: `(min-width: 1488px) 1232px, 85vw`,
min: 864,
max: 1232,
}

This object has a max of 1232px. In the sizes key, we can see that starting at a screen width of 1488px (determined by trial and error), the image is 1232px wide, and never gets wider, so we can hardcode the px value in the sizes key and put the pixel value in the max key. If you want to optimize for retina displays, there’s an added consideration related to the maximum size of your image. Casper’s ResponsiveImage component, for example, handles this by using a util that determines the maximum width of the image by multiplying the max value passed in the image config by 1.5. This means the width of the image asset should be at least 1.5x the width passed in the max key. Finally, for images that are a fixed width, like a thumbnail, you can omit the max value.

To test your image config, start at the smallest possible screen size (320px). Note that if you start with the largest possible screen and narrow it, the images could look fine — even if the config is off— because you’re loading bigger images than necessary. The best way to test is to move your Chrome DevTools console to the right side of the screen and use that to narrow the screen size, as shown below. If you use dev tools to select a size of 320px, it will emulate a higher Device Pixel Ratio and won’t give accurate sizes. Inspect the image and hover over the image source to view the currentSrc:

Note that the width will usually be rounded to the nearest hundred, so for a screen width of 320px, 256px will be rounded to 300px. The only time the width is not rounded is if an image width for a breakpoint is given in pixels.

Even after all these careful calculations, you might notice during testing that the image width estimates we’re passing via the config object are slightly off, resulting in bugs like this:

As you can see, the “new” badge in the upper right corner and the button in the lower left corner look misaligned because the image isn’t quite wide enough.

To fix this, we can wrap the ResponsiveImagecomponent in a div with position: relative and set the width of the img to 100%. Here’s our styled component version:

export const ImageWrapper = styled.div`
position: relative;
  img {
width: 100%;
}
`;

And that’s it! Now that you know how to read, write and test image configs, you can optimize your images and cut down on page load times. Shout out to Joseph Cortbawi for sharing his vast knowledge about responsive images with me!


If you’re interested in learning more about how we tackle the day-to-day challenges of building an e-comm site, check out our jobs page for opportunities to the join the team. We’re always looking for talented engineers to help us bring Casper to the next level.