DEV Community

Cover image for A minimal, multi-framework, responsive image component
Matt Kane
Matt Kane

Posted on

A minimal, multi-framework, responsive image component

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. The fullWidth layout is designed for hero images, and has a default set of breakpoints based on all popular screen widths. The fixed 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"
    />
  );
}

Enter fullscreen mode Exit fullscreen mode

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&amp;width=1080&amp;height=1440 1080w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=1280&amp;height=1707 1280w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=1600&amp;height=2133 1600w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=640&amp;height=853 640w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=750&amp;height=1000 750w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=800&amp;height=1067 800w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=828&amp;height=1104 828w,
https://cdn.shopify.com/static/sample-images/bath.jpeg?crop=center&amp;width=960&amp;height=1280 960w"
        src="https://cdn.shopify.com/static/sample-images/bath.jpeg?width=800&amp;height=600&amp;crop=center">
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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"
/>

Enter fullscreen mode Exit fullscreen mode

...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"
    />
  );
};
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
steve8708 profile image
Steve Sewell

LOVE this. Been thinking this has been needed for so long, so happy to see you make it Matt!

Collapse
 
rockykev profile image
Rocky Kev

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.

Collapse
 
jjgmckenzie profile image
James McKenzie

Very cool work!

Collapse
 
kurtextrem profile image
Jacob "kurtextrem" Groß

I think I saw that one on Twitter and found it interesting. However, how sizes and srcset 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?

Collapse
 
opendataanalytics profile image
The Open Coder

Excellent work, great explainations!

Collapse
 
outranker profile image
Justin

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&amp;width=1080&amp;height=1440 1080w

/bath_grande_crop_center.jpeg and bath.jpeg

Collapse
 
ascorbic profile image
Matt Kane

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, and crop_center is translated to crop=center.

Collapse
 
50bbx profile image
50bbx • Edited

Just a small thing: aspect-ratio is supported from Safari 15 onwards and Safari 14 is not that old.