Meet Unpic img
Sat Feb 11 2023
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"
/>
);
};