Doing web images right can be hard. The <img>
tag is just the starting point. In 2023, if you want the best performance you should be:
- using
srcset
to deliver multiple resolutions for different device and screen sizes - using
sizes
so that the browser knows which image resolution to download - delivering modern image formats such as AVIF and WebP if the browser supports them
- ensuring that the image resizes responsively, maintaining aspect ratio
- avoids layout shift when the images has loaded
- use native lazy-loading and async decoding for offscreen images
- use high priority fetching for critical images
- supports placeholders for lazy-loaded images
This isn't realistically something that can be done manually, but luckily many web frameworks provide tools to handle this. Depending on the framework, these may handle image resizing at build time or runtime, and may provide a component that makes it easy to embed the images. These all have drawbacks though - resizing at build time is slow, and the components often generate complex markup that is hard to style.
Let the CDN do the work
A lot of the trouble with embedding images is generating all the different sizes. A great way to solve this is with an image CDN, which resizes the image on the fly. You may have heard of the big names Cloudinary and Imgix, but what you might not know is that lots of other images that you're using are already on image CDNs. For example, CMSs such as Contentful, Sanity, Prismic and WordPress.com all deliver their images from a CDN that can resize on the fly. Shopify does too, as well as Unsplash. If your framework is downloading and resizing these then it is a huge waste. Next.js is a particularly egregious one here. I was curious about this and ran some queries on the data at Netlify โย more than half of all next/image
requests served by Netlify were for images from CDNs that could handle their own resizing.
Inspired by this, I built unpic, a library for detecting, parsing and generating image CDN URLs. The next step from that was to use this to create an image component that take any image CDN URL and generates all of the correct source images.
Unpic img: a simpler image component for every framework
I have created Unpic img, a minimal image component that makes it easy to do images well. It has some features that make it stand out:
- It's just an
<img>
tag! No wrappers, no spacers. It doesn't even need a<picture>
tag. - Just HTML and CSS. If it's pre-rendered there is no runtime JS at all.
- Best practices by default. Large image, above the fold? Pass
priority
and it will ensure it's loaded with high priority fetch to keep your LCP low. Otherwise it will lazy-load it and use async decoding. - Choice of layouts. By default it uses
constrained
layout, which has a maximum image size but will scale down for smaller screens, maintaining aspect ratio. ThefullWidth
layout is designed for hero images, and has a default set of breakpoints based on all popular screen widths. Thefixed
layout is what it sounds like, but ensures it still generates the right sources for Retina displays. - Multi-framework. Currently it supports React, Vue, SolidJS and Svelte. Because there is no runtime script, it's simple to support multiple frameworks and PRs are welcome to add more. All the logic is in a shared
@unpic/core
library. - Simple API. It's an
img
tag, but better. Accepts any<img>
attribute.
Here's what it looks like in React:
import { Image } from "@unpic/react";
function MyComponent() {
return (
<Image
src="https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg"
layout="constrained"
width={800}
height={600}
alt="A lovely bath"
/>
);
}
This generates the following HTML:
<img alt="A lovely bath" loading="lazy" decoding="async" sizes="(min-width: 800px) 800px, 100vw"
style="object-fit: cover; max-width: 800px; max-height: 600px; aspect-ratio: 1.33333 / 1; width: 100%;"
srcset="https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=1080&height=1440 1080w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=1280&height=1707 1280w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=1600&height=2133 1600w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=640&height=853 640w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=750&height=1000 750w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=800&height=1067 800w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=828&height=1104 828w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=960&height=1280 960w"
src="https://cdn.shopify.com/static/sample-images/bath.jpeg?width=800&height=600&crop=center">
I know which one I'd rather write!
The equivalent code for Vue:
<script setup lang="ts">
import { Image } from "@unpic/vue";
</script>
<template>
<Image
src="https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg"
layout="constrained"
width="800"
height="600"
alt="A lovely bath"
/>
</template>
Svelte:
<script lang="ts">
import { Image } from "@unpic/svelte";
</script>
<Image
src="https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg"
layout="constrained"
width={800}
height={600}
alt="A lovely bath"
/>
...and SolidJS:
import type { Component } from "solid-js";
import { Image } from "@unpic/solid";
const MyComponent: Component = () => {
return (
<Image
src="https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg"
layout="constrained"
width={800}
height={600}
alt="A lovely bath"
/>
);
};
It's very much a work in progress right now, but give it a try and let me know what you think. If you'd like to contribute a supported framework then PRs are welcome.
I'm Matt Kane, and I'm a principal engineer at Netlify, where I work on framework integrations. Previously I helped built the Gatsby image plugin
Top comments (8)
LOVE this. Been thinking this has been needed for so long, so happy to see you make it Matt!
I absolutely love this! I've been wrestling with this issue for a few years, using different methods with each framework I use. Image markup has gotten complicated and doing it yourself with a component is just beautiful.
Very cool work!
I think I saw that one on Twitter and found it interesting. However, how
sizes
andsrcset
is setup currently leads to devices with 2x or 3x DPR to download higher-than-needed resolutions (see my article for more details kurtextrem.de/posts/modern-way-of-...). Maybe we can combine our efforts to change unpic to support high-DPR devices without downloading too much?Excellent work, great explainations!
I noticed the image url is different in generated code. Is there any reason for that?
<Image
src="https://cdn.shopify.com/static/sample-images/bath_grande_crop_center.jpeg"
layout="constrained"
width={800}
height={600}
alt="A lovely bath"
/>
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&width=1080&height=1440 1080w
/bath_grande_crop_center.jpeg
andbath.jpeg
Yeah. The other URL uses Shopify's old URL API that includes the sizing params in the filename. It normalises URLs to use the new, param-based API which is much easier to manipulate. In that example
grande
is a preset size which is overridden by the library, andcrop_center
is translated tocrop=center
.Just a small thing:
aspect-ratio
is supported from Safari 15 onwards and Safari 14 is not that old.