Astro Picture Component: adding Responsive Images

Rodney Lab - Nov 13 '23 - - Dev Community

🚀 Adding Responsive Images to Astro Blog Posts

Let’s take a look at the built-in Astro Picture component. Astro Content Collections offer a fantastic Developer Experience for building Markdown blog or documentation sites, but did you know how well they dovetail with the Picture component?

The Astro Picture component does the heavy lifting in adding responsive image markup to your content sites. If you have ever had to construct HTML <picture> tags from scratch, you will know supporting devices with different capabilities and screen-sizes in a performant way is not easy.

Usually with responsive images, we mean serving large, double pixel density images, for users with Retina displays, but dialling down the resolution for a user on a smaller mobile device, which is able to make the image look equally sharp with a fraction of the pixels.

Responsive and Next-gen Images

Sending the right sized image for the user device, reduces the number of bytes streamed. Another popular way of reducing shipped image bytes is using WebP and AVIF images. These next-gen formats should encode images more efficiently, allowing for smaller files, though older devices do not support them.

😕 The Astro Picture Component

The HTML <picture> tag, essentially, is a way of communicating to the browser which formats you have available on the server and in what sizes, and letting the browser pick the most suitable one. You can imagine, adding this much detail for each image can become cumbersome. And we haven’t even mentioned adding markup to prevent Cumulative Layout Shift (CLS) yet!

The Astro Picture component is designed to help you here, saving you from having to construct that HTML <picture> tag yourself. In this post, we see how you can work with the Astro Picture component to add a banner image to each post on a blog site. As a bonus, we also look at using Astro Content Collections, to generate a low-resolution placeholder image to display while the banner image is loading.

🧱 What are we Building?

Instead of building a new blog from start-to-finish, we’ll just see the steps you need to add banner images to blog posts to an existing Astro Markdown site with Content Collections. There is a link to the complete project code further down the page.

👮🏽 Content Collections

Content collections are invaluable for making sure your content Markdown front matter has all the fields you need it to have. The schema uses zod under the hood, so you might already be familiar with the syntax. Here, we will see how you can also use the schema to generate the image metadata needed for use with Astro’s Picture component.

---
postTitle: 'Best Medium Format Camera for Starting Out'
datePublished: '2023-10-07T16:04:42.000+0100'
lastUpdated: '2022-10-23T10:17:52.000+0100'
featuredImage: './best-medium-format-camera-for-starting-out.jpg'
featuredImageAlt: 'Photograph of a Hasselblad medium format camera with the focusing screen exposed'
---

## What is a Medium Format Camera?

If you are old enough to remember the analogue film camera era, chances are it is the 35&nbsp;mm canisters with the track cut down the side that first come to mind. Shots normally...
Enter fullscreen mode Exit fullscreen mode

The first step is to place the banner image in the content folder, within your Astro project. Here, I copied the image I want to use to the same directory as the Markdown file containing the post content.

Then, above, you can see I added a featuredImage field to the post front matter, with the path to the image (path is relative to the Markdown file’s own path, src/content/posts/best-medium-format-camera-for-starting-out/index.md). Equally important is the image alt text (in line 6), which we also want to feed into the Astro Picture component.

Schema File

The next change is to the schema file (at src/content/config.ts). A schema is not required for using Content Collections generally, though you will need one to generate the image meta. See zod docs for explanation of anything that you are not familiar with, or drop a question below in the comments.

import { defineCollection, z } from 'astro:content';

const postsCollection = defineCollection({
    type: 'content',
    schema: ({ image }) =>
        z.object({
            postTitle: z.string(),
            datePublished: z.string(),
            lastUpdated: z.string(),
            featuredImage: image(),
            featuredImageAlt: z.string(),
            ogImage: z.string().optional(),
            draft: z.boolean(),
        }),
});

export const collections = {
    posts: postsCollection,
};
Enter fullscreen mode Exit fullscreen mode

For Astro to generate the image metadata, just include the image function as an argument to the callback in line 5, and call that same function on the featuredImage field. If you include multiple image fields in the front matter, you can call image on each.

Calling image automatically generates the following fields for the picture, which we use in the next section:

const featuredImage: {
    src: string;
    width: number;
    height: number;
    format: "png" | "jpg" | "jpeg" | "tiff" | "webp" | "gif" | "svg" | "avif";
}
Enter fullscreen mode Exit fullscreen mode

🧩 Adding the Astro Picture Component

The final missing piece of the puzzle is pulling in the metadata generated in the schema to an instance of the Astro Picture component. The first step, then, is to extract that metadata within the front matter section of the blog post page template (src/pages/[slug].astro):

---
import { Picture } from 'astro:assets';
import type { CollectionEntry } from 'astro:content';
import { getCollection } from 'astro:content';
import BaseLayout from '~layouts/BaseLayout.astro';

export async function getStaticPaths() {
    const posts = await getCollection('posts');
    return posts.map(
        ({
            data: { featuredImage, featuredImageAlt, postTitle },
            render,
            slug,
        }) => {
            return {
                params: { slug },
                props: {
                    featuredImage,
                    featuredImageAlt,
                    render,
                    postTitle,
                },
            };
        },
    );
}

type PostCollection = CollectionEntry<'posts'>;
type Props = Pick<
    PostCollection['data'],
    'featuredImage' | 'featuredImageAlt' | 'seoMetaDescription' | 'placeholder' | 'postTitle'
> &
    Pick<PostCollection, 'render'>;

const { slug } = Astro.params;
const {
    featuredImage,
    featuredImageAlt,
    render,
    postTitle: title,
}: Props = Astro.props;

const { Content } = await render();

const { format, width, height } = featuredImage;
---
Enter fullscreen mode Exit fullscreen mode

Adding the Picture Component to Astro Markup

To finish off, let’s add the Astro Picture component to the markup:

---
// TRUNCATED...
---

<BaseLayout {...seoProps}>
    <h1>{title}</h1>
    <div class="image-wrapper">
        <Picture
            src={featuredImage}
            alt={featuredImageAlt}
            densities={[1.5, 2]}
            formats={['avif', 'webp']}
            fallbackFormat={format}
            loading="eager"
            fetchpriority="high"
        />
    </div>

    <div class="container">
        <Content />
    </div>
</BaseLayout>
Enter fullscreen mode Exit fullscreen mode

Notice, we also have densities, formats and fallbackFormat props. These are used to generate the final HTML <picture> tag.

  • densities are the pixel densities used for high resolution screens, like Retina displays, typically 1.5 and 2 will be just fine here.
  • formats are the next-gen formats you want the images available in. To be safe, you might choose just webp as there have been issues with displaying some avif images in Safari. An alternative is to use content negotiation.
  • fallbackFormat this is the format you want older browsers to use (browsers which do not support WebP or AVIF), and is probably the format of the original image.

Generated HTML Markup

Using the above parameters, the final markup looks something like this:

<picture
  ><source
    srcset="
      /_astro/best-medium-format-camera-for-starting-out.8696310b_Zzixeg.avif,
      /_astro/best-medium-format-camera-for-starting-out.8696310b_Zzixeg.avif 1.5x,
      /_astro/best-medium-format-camera-for-starting-out.8696310b_Zzixeg.avif 2x
    "
    type="image/avif" />
  <source
    srcset="
      /_astro/best-medium-format-camera-for-starting-out.8696310b_Oo3Ed.webp,
      /_astro/best-medium-format-camera-for-starting-out.8696310b_Oo3Ed.webp 1.5x,
      /_astro/best-medium-format-camera-for-starting-out.8696310b_Oo3Ed.webp 2x
    "
    type="image/webp" />
  <img
    src="/_astro/best-medium-format-camera-for-starting-out.8696310b_1MPGLA.jpg"
    srcset="
      /_astro/best-medium-format-camera-for-starting-out.8696310b_1MPGLA.jpg 1.5x,
      /_astro/best-medium-format-camera-for-starting-out.8696310b_1MPGLA.jpg 2x
    "
    alt="Photograph of a Hasselblad medium format camera with the focusing screen exposed"
    loading="eager"
    data-astro-cid-yvbahnfj=""
    width="1344"
    height="896"
    decoding="async"
/></picture>
Enter fullscreen mode Exit fullscreen mode

Notice the image URLs include a hash in the path, which is ideal for cache-busting.

🏆 Level it up: Low Resolution Placeholder

You might not have known it is possible to transform front matter fields within the Content Collection schema file. We can use this feature to generate low resolution placeholders automatically, using the ThumbHash algorithm.

To start, let’s add a placeholder field to the front matter:

---
postTitle: 'Best Medium Format Camera for Starting Out'
datePublished: '2023-10-07T16:04:42.000+0100'
lastUpdated: '2022-10-23T10:17:52.000+0100'
featuredImage: './best-medium-format-camera-for-starting-out.jpg'
featuredImageAlt: 'Photograph of a Hasselblad medium format camera with the focusing screen exposed'
placeholder: 'best-medium-format-camera-for-starting-out/best-medium-format-camera-for-starting-out.jpg'
---
Enter fullscreen mode Exit fullscreen mode

To avoid using undocumented Astro APIs (which might later change), I have included the post folder name in the path here. This is different to how we added the featured image itself.

Next, we can add the transformation to our schema (src/content/config.ts):

import { image_placeholder } from '@rodneylab/picpack';
import { defineCollection, z } from 'astro:content';
import { readFile } from 'node:fs/promises';

const postsCollection = defineCollection({
    type: 'content',
    schema: ({ image }) =>
        z.object({
            postTitle: z.string(),
            datePublished: z.string(),
            lastUpdated: z.string(),
            featuredImage: image(),
            featuredImageAlt: z.string(),
            ogImage: z.string().optional(),
            placeholder: z.string().transform(async (value) => {
                const { buffer } = await readFile(`./src/content/posts/${value}`);
                const imageBytes = new Uint8Array(buffer);
                const { base64 } = image_placeholder(imageBytes);
                return base64;
            }),
            draft: z.boolean(),
        }),
});

export const collections = {
    posts: postsCollection,
};
Enter fullscreen mode Exit fullscreen mode

Here, I added a Rust WASM helper package (@rodneylab/picpack) to generate the placeholder using ThumbHash. The arrow function in the placeholder field (lines 15-19) reads the bytes from the image and generates a Base64-encoded placeholder. We took the image path and transformed the field to make a placeholder Base64-encoded string available in the Astro page template.

Placeholder Markup

---
// TRUNCATED...
---

<BaseLayout {...seoProps}>
    <h1>{title}</h1>
    <div class="image-wrapper">
        <img class="placeholder" aria-hidden="true" src={placeholder} width={width} height={height} />
        <Picture
            pictureAttributes={{ class: 'image lazy' }}
            src={featuredImage}
            alt={featuredImageAlt}
            densities={[1.5, 2]}
            formats={['avif', 'webp']}
            fallbackFormat={format}
            loading="eager"
            fetchpriority="high"
        />
    </div>

    <div class="container">
        <Content />
    </div>
</BaseLayout>
Enter fullscreen mode Exit fullscreen mode

We have an extra <img> element, which will contain the placeholder. Its src is the Base64 placeholder, which we just generated in the schema transformation. Notice in line 60, the pictureAttributes prop lets us add a class to the generated <picture> tag.

The final missing piece is a spot of CSS to place the full-scale image on top of the placeholder, so that it displays (over the placeholder) as soon as it loads:

img,
picture {
    display: block;
    max-width: 100%;
    height: auto;
}

.image-wrapper {
    display: grid;

    .placeholder,
    .image {
        display: block;
        grid-area: 1/1/1/1;
        height: auto;
        aspect-ratio: 3/2;
    }
}
Enter fullscreen mode Exit fullscreen mode

You can go to town on this, adding a touch of JavaScript to blend the placeholder into the full-scale image once it is loaded, though we won’t get into that here.

Astro Picture Component Alternatives

Hopefully, this has given you an idea of how the Astro Picture component works, and whether it will be suitable for your own project. You might also consider the unpic project as an alternative. It is designed to work with hosting services (such as Imgix), and has an Astro-specific image component.

You can also roll your own. The sharp image plugin is a good starting point. It is performant, and runs in environments other than Node.js: Deno and Bun, for example.

🙌🏽 Astro Picture Component: Wrapping Up

In this post, we saw how you can add responsive banner images to your Astro blog Markdown posts. In particular, we saw:

  • how you can automatically generate image metadata using Content Collection schema;
  • how you can transform front matter fields to generate Base64 placeholder images from image paths; and
  • some options available on the Picture element to control the generation of next-gen AVIF and WebP alternatives for devices of different display sizes.

You can see the full code for this project in the Rodney Lab GitHub repo. I do hope you have found this post useful! Let me know how you add pictures to your site, whether you use the Astro Picture components, go for unpic with a hosting service or build something more manual. Also, let me know about any possible improvements to the content above.

🙏🏽 Astro Picture Component: Feedback

Have you found the post useful? Would you prefer to see posts on another topic instead? Get in touch with ideas for new posts. Also, if you like my writing style, get in touch if I can write some posts for your company site on a consultancy basis. Read on to find ways to get in touch, further below. If you want to support posts similar to this one and can spare a few dollars, euros or pounds, please consider supporting me through Buy me a Coffee.

Finally, feel free to share the post on your social media accounts for all your followers who will find it useful. As well as leaving a comment below, you can get in touch via @askRodney on X, @rodney@toot.community on Mastodon and also the #rodney Element Matrix room. Also, see further ways to get in touch with Rodney Lab. I post regularly on Astro as well as SEO. Also, subscribe to the newsletter to keep up-to-date with our latest projects.

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .