Astro Responsive Image

Published: September 13, 2023 Updated: September 19, 2023

Astro Responsive Image

Table of Contents

Using <img /> and srcset

The following is a custom component for Astro that generates a responsive image using the srcset and sizes attributes. It also uses the getImage function from the astro:assets module to generate the image URLs. The function uses the same compression as the built-in <Image /> component from Astro.

For more information on responsive images, see the MDN article.

---
import type { ImageOutputFormat } from "astro";
import { getImage, type ImgAttributes } from "astro:assets";

type Unit = "px" | "em" | "rem";
type Size = `${number}${Unit}`;
type MinMediaQuery = `(min-width: ${Size})`;
type MaxMediaQuery = `(max-width: ${Size})`;
type OtherMediaQuery =
	| "(orientation: portrait)"
	| "(orientation: landscape)"
	| "(prefers-color-scheme: dark)"
	| "(prefers-color-scheme: light)";
type MediaQueries = MinMediaQuery | MaxMediaQuery | OtherMediaQuery;

type Props = Omit<ImgAttributes, "sizes"> & {
	src: ImageMetadata;
	format?: ImageOutputFormat;
	srcSet: {
		media?: `${MediaQueries}` | `(${MinMediaQuery} and ${MaxMediaQuery})` | `(${MaxMediaQuery} or ${MinMediaQuery})`;
		size: number;
	}[];
};

const { src, format, alt, id, srcSet } = Astro.props;
const className = Astro.props.class;

if (!srcSet.length) throw new Error("No srcSet provided");

const imgSrc = await getImage({ src, width: Math.min(...srcSet.map((ss) => ss.size)), format });
const srcset = await Promise.all(
	srcSet.map(async (ss) => {
		const image = await getImage({ src, width: ss.size, format });
		return `${image.src} ${ss.size}w`;
	})
);
const sizes = srcSet.map((ss) => (ss.media ? `${ss.media} ${ss.size}px` : `${ss.size}px`));
---
<img srcset={srcset.join(", ")} sizes={sizes.join(", ")} {id} {alt} src={imgSrc.src} class={className} loading="lazy" />

Example

// Post Cover Image
<ResponsiveImage
  src={image}
  alt={title}
  id="post-image"
  srcSet={[
    { media: "(max-width: 500px)", size: 500 },
    { media: "(max-width: 700px)", size: 700 },
    { size: 900 }
  ]}
  class="h-full w-full object-cover object-center shadow shadow-black/50"
/>

// Post Card Image
<ResponsiveImage
  src={post.data.image}
  alt={post.data.title}
  srcSet={[
    { media: "(min-width: 564px)", size: 424 },
    { size: 640 }
  ]}
  class="h-full w-full object-cover object-center"
/>

The Result

<!-- Post Cover Image -->
<img srcset="/_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=500&amp;f=webp 500w, /_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=700&amp;f=webp 700w, /_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=900&amp;f=webp 900w" sizes="(max-width: 500px) 500px, (max-width: 700px) 700px, 900px" id="post-image" alt="Astro Responsive Image" src="/_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=500&amp;f=webp" class="h-full w-full object-cover object-center shadow shadow-black/50" loading="lazy">

<!-- Post Card Image -->
<img srcset="/_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=424&amp;f=webp 424w, /_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=640&amp;f=webp 640w" sizes="(min-width: 564px) 424px, 640px" alt="Astro Responsive Image" src="/_image?href=[redacted]%3ForigWidth%3D1600%26origHeight%3D840%26origFormat%3Dwebp&amp;w=424&amp;f=webp" class="h-full w-full object-cover object-center" loading="lazy">

Using <picture> and <source>

The following is a custom component for Astro that generates a responsive image using the <picture> element and <source> elements. It also uses the getImage function from the astro:assets module to generate the image URLs. The function uses the same compression as the built-in <Image /> component from Astro.

---
import { cn } from "$/util";
import type { ImageOutputFormat } from "astro";
import { getImage, type ImgAttributes } from "astro:assets";

type Unit = "px" | "em" | "rem";
type Size = `${number}${Unit}`;
type MinMediaQuery = `(min-width: ${Size})`;
type MaxMediaQuery = `(max-width: ${Size})`;
type MinMaxMediaQuery =
	| MinMediaQuery
	| MaxMediaQuery
	| `${MinMediaQuery} and ${MaxMediaQuery}`
	| `(${MaxMediaQuery} or ${MinMediaQuery})`;
type OtherMediaQuery =
	| "(orientation: portrait)"
	| "(orientation: landscape)"
	| "(prefers-color-scheme: dark)"
	| "(prefers-color-scheme: light)";
type OMMMediaQuery = `${OtherMediaQuery} and ${MinMaxMediaQuery}`;

type Props = ImgAttributes & {
	src: ImageMetadata;
	pictureClass?: string | string[];
	include2x?: boolean;
	include3x?: boolean;
	srcSet: {
		src?: ImageMetadata;
		media: MinMaxMediaQuery | OtherMediaQuery | OMMMediaQuery;
		size: number;
		format?: ImageOutputFormat;
	}[];
};

const { src, srcSet, pictureClass, include2x, include3x, ...rest } = Astro.props;
const className = Astro.props.class;
const loading = Astro.props.loading || "lazy";

const srcset = (
	await Promise.all(
		srcSet.map(async ({ media, size, format, ...img }) => {
			const url = src || img.src;
			const images: string[] = [];

			const image = await getImage({ src: url, width: size, format });
			images.push(`${image.src}`);

			if (include2x) {
				const image2x = await getImage({ src: url, width: size * 2, format });
				images.push(`${image2x.src} 2x`);
			}

			if (include3x) {
				const image3x = await getImage({ src: url, width: size * 3, format });
				images.push(`${image3x.src} 3x`);
			}

			return {
				src: images.join(", "),
				media,
				type: format && `image/${format}`,
				size
			};
		})
	)
).sort((a, b) => a.size - b.size);

const maxSize = srcset.reduce((acc, curr) => (curr.size > acc ? curr.size : acc), 0);
const imgSrc = srcset.find((ss) => ss.size === maxSize);
if (!imgSrc) throw new Error("No image source found");
---
<picture class={cn("h-full w-full", pictureClass)}>
	{srcset.map((ss) => <source srcset={ss.src} media={ss.media} type={ss.type} />)}
	<img src={imgSrc.src.split(", ")[0]} loading={loading} class={className} {...rest} />
</picture>

Example

// Post Cover Image
<ResponsiveImage
  src={image}
  alt={title}
  id="post-image"
  srcSet={[
    { media: "(max-width: 500px)", size: 500 },
    { media: "(max-width: 700px)", size: 700 },
    { media: "(min-width: 701px)", size: 900 }
  ]}
  class="h-full w-full object-cover object-center shadow shadow-black/50"
/>

// Post Card Image
<ResponsiveImage
  src={post.data.image}
  alt={post.data.title}
  srcSet={[
    { media: "(min-width: 564px)", size: 424 },
    { media: "(max-width: 564px)", size: 640 }
  ]}
  class="h-full w-full object-cover object-center"
/>

The Result

<!-- Post Cover Image -->
<picture class="h-full w-full">
	<source srcset="/_astro/astro.b134230a_1zSOD8.webp" media="(max-width: 500px)">
	<source srcset="/_astro/astro.b134230a_ZoRSGi.webp" media="(max-width: 700px)">
	<source srcset="/_astro/astro.b134230a_Z2oEC1I.webp" media="(min-width: 701px)">
	<img src="/_astro/astro.b134230a_Z2oEC1I.webp" loading="lazy" class="h-full w-full object-cover object-center shadow shadow-black/50" alt="Astro Responsive Image" id="post-image">
</picture>

<!-- Post Card Image -->
<picture class="h-full w-full">
	<source srcset="/_astro/astro.b134230a_Z10wjS6.webp" media="(min-width: 564px)">
	<source srcset="/_astro/astro.b134230a_Z24VRyK.webp" media="(max-width: 564px)">
	<img src="/_astro/astro.b134230a_Z24VRyK.webp" loading="lazy" class="h-full w-full object-cover object-center" alt="Astro Responsive Image">
</picture>