Skip to content

Instantly share code, notes, and snippets.

@sannajammeh
Last active October 19, 2024 04:03
Show Gist options
  • Save sannajammeh/d1dfddc8c2538062de53714b07953cdf to your computer and use it in GitHub Desktop.
Save sannajammeh/d1dfddc8c2538062de53714b07953cdf to your computer and use it in GitHub Desktop.
Astro Responsive Image

Astro responsive image (Local images only for now)

This component works just like the original image from astro:assets, however it comes with responsive image generation.

Make sure to install peer deps for blur placeholder to work.

pnpm install lqip-modern

It must be used with dynamic image data like so:

---
import Image from "@/components/image";

// Your local image 
import localImg from "@/assets/my-local.jpg" // My extra large image 1920x1080
---
  
<Image src={localImg} alt="original" /> // Load full resolution image, but downscale based on viewport size
/*
* Set the maximum generated width to 400px
* If a screen is 320px wide, browser will load 320px wide image.
*/
<Image src={localImg} width={800} alt="smaller" class="w-full" />

/**
* Tell browser this image is 50% of the viewport width
* Browser will figure out what width to generate. 
* I.e if laptop@1280 -> 640px is loaded, if desktop@1920 -> 1200px is loaded
*/
<Image src={localImg} sizes="50vw" alt="half-screen" class="w-full" />

Blurred placeholder

Avoid white image of death and apply a 16px blurred placeholder using lqip-modern.

<Image placeholder="blur" src={localImg} sizes="50vw" alt="half-screen" class="w-full" />

Vercel's image service

In order to improve build times you can optionally use Vercel's image service for the assets. Now the Image component will function exactly like Next.js' Image minus their layout prop.

// astro.config.mjs
{
// ...config
  adapter: vercel({
    imageService: true
  }),
}
/**
* A shared function, used on both client and server, to generate a SVG blur placeholder.
* Taken for Next.js 13 - Thanks vercel!
*/
export function getImageBlurSvg({
widthInt,
heightInt,
blurWidth,
blurHeight,
blurDataURL,
objectFit,
}: {
widthInt?: number;
heightInt?: number;
blurWidth?: number;
blurHeight?: number;
blurDataURL: string;
objectFit?: string;
}): string {
const std = 20;
const svgWidth = blurWidth ? blurWidth * 40 : widthInt;
const svgHeight = blurHeight ? blurHeight * 40 : heightInt;
const viewBox =
svgWidth && svgHeight ? `viewBox='0 0 ${svgWidth} ${svgHeight}'` : "";
const preserveAspectRatio = viewBox
? "none"
: objectFit === "contain"
? "xMidYMid"
: objectFit === "cover"
? "xMidYMid slice"
: "none";
return `%3Csvg xmlns='http://www.w3.org/2000/svg' ${viewBox}%3E%3Cfilter id='b' color-interpolation-filters='sRGB'%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3CfeColorMatrix values='1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 100 -1' result='s'/%3E%3CfeFlood x='0' y='0' width='100%25' height='100%25'/%3E%3CfeComposite operator='out' in='s'/%3E%3CfeComposite in2='SourceGraphic'/%3E%3CfeGaussianBlur stdDeviation='${std}'/%3E%3C/filter%3E%3Cimage width='100%25' height='100%25' x='0' y='0' preserveAspectRatio='${preserveAspectRatio}' style='filter: url(%23b);' href='${blurDataURL}'/%3E%3C/svg%3E`;
}
---
import type { ImageMetadata, ImageTransform } from "astro";
import { getImage } from "astro:assets";
import LoadableImage from "./LoadableImage.astro";
type Props = {
src: ImageMetadata;
alt: string;
/**
* Array of screens to generate the image for i.e [320, 480, 1200]
* Maximum width will be 1200 regardless if screen is larger
*/
deviceSizes?: number[];
placeholder?: "blur" | "empty";
} & Omit<ImageTransform, "src"> &
Omit<astroHTML.JSX.ImgHTMLAttributes, "src" | "width" | "height">;
const {
src,
format,
width: propsWidth,
height,
quality,
alt,
srcset: _,
loading = "lazy",
decoding = "async",
sizes,
deviceSizes = [320, 480, 640, 750, 828, 1200, 1920, 2048, 3840],
placeholder: placeholderType,
...rest
} = Astro.props;
if (!src) {
throw new Error("src is required");
}
const originalWidth = src.width;
const originalHeight = src.height;
const aspectRatio = originalHeight / originalWidth;
// If propsWidth is provided, use it as the maximum size
const maxWidth =
propsWidth && propsWidth <= originalWidth ? propsWidth : originalWidth;
// Filter out sizes that are larger than the max width
const validSizes = deviceSizes.filter((size) => size <= maxWidth);
const generateSrcSet = async (size: number) => {
const resizedImg = await getImage({
src,
format,
width: size,
height: Math.round(size * aspectRatio),
quality,
} as ImageTransform);
return `${resizedImg.src} ${size}w`;
};
const srcsetPromises = validSizes.map(generateSrcSet);
const srcsetResults = await Promise.all(srcsetPromises);
const srcset = srcsetResults.join(", ");
const sizesAttrMapped = validSizes
.map((size) => `(max-width: ${size}px) ${size}px`)
.join(", ");
const sizesAttr = `
${sizesAttrMapped},
${maxWidth}px
`;
const original = await getImage({
src,
format,
width: maxWidth,
height: Math.round(maxWidth * aspectRatio),
quality,
} as ImageTransform);
---
{
placeholderType === "blur" ? (
<LoadableImage
src={original.src}
srcset={srcset}
sizes={sizes || sizesAttr}
alt={alt}
loading={loading}
decoding={decoding}
srcMeta={src}
generated={original}
{...(rest as any)}
/>
) : (
<img
src={original.src}
srcset={srcset}
sizes={sizes || sizesAttr}
alt={alt}
loading={loading}
decoding={decoding}
{...rest}
/>
)
}
---
import type { ImageMetadata } from "astro";
import { getImageBlurSvg } from "./image-blur-svg";
import lqip from "lqip-modern";
import path from "path";
type Props = {
src: string;
alt: string;
width?: number | string;
height?: number | string;
srcMeta: ImageMetadata;
[key: string]: any;
} & Omit<astroHTML.JSX.ImgHTMLAttributes, "src" | "width" | "height">;
const {
src,
alt,
decoding,
loading,
sizes,
srcset,
width,
height,
style,
srcMeta,
generated,
...rest
} = Astro.props;
let sourceLocation: string;
if (import.meta.env.MODE === "development") {
sourceLocation = srcMeta.src.replace("/@fs", "").split("?")[0];
} else {
const cwd = process.cwd();
// We need to resolve /_astro to <process.cwd>/dist/_astro
const location = path.join(cwd, "dist", srcMeta.src);
sourceLocation = location;
}
const blurData = await lqip(sourceLocation).then((r) => r.metadata);
// Generate placeholder
const svgUrl = `url("data:image/svg+xml;charset=utf-8,${getImageBlurSvg({
widthInt: srcMeta.width,
heightInt: srcMeta.height,
blurWidth: blurData.width,
blurHeight: blurData.height,
blurDataURL: blurData.dataURIBase64,
objectFit: "cover",
})}")`;
const onLoad = `
this.style.setProperty('--placeholder', null);
this.removeAttribute('onload');
`;
---
<img
src={src}
srcset={srcset}
sizes={sizes}
alt={alt}
loading={loading}
decoding={decoding}
onload={onLoad.trim()}
{...rest}
/>
<style lang="scss" define:vars={{ placeholder: svgUrl }}>
img {
background-image: var(--placeholder);
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 1;
z-index: 1;
}
</style>
@johny
Copy link

johny commented Oct 19, 2023

Nice solution! Do you know if support for blurred placeholders is coming to original Astro Image component?

@sannajammeh
Copy link
Author

Not sure, Astro maintainer expressed excitement when he saw it so maybe 😄

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment