How We Dynamically Select Images With Astro
Jun 19, 2024

Dynamic Images in Astro with Perspect

Astro is an amazing framework. Perspect is framework agnostic, but some of us have taken a particular liking to qualities of Astro that allow for both statically generated content and client-side "hydration" where it makes sense. One can even combine those two things together.     One of the areas where it makes sense to combine static page generation and dynamism is for images. It's ideal for the overall page to be statically generated, but for the image sizes to be dynamically chosen by the browser so that a very large resolution image does not get used for small resolution oriented mobile browsers.     Enter the concept of "islands." Here's how to make this work in Perspect, but you can probably adapt it to other services too:   

Define the getMediaFile function

  In src/components/content.js, define the getMediaFile function that will run client-side and allow the browser to select the correct image size to be displayed:    

export const getMediaFile = (media) => {
    // Define media query conditions for different image size categories
    const mediaQueries = {
        very_small: "(max-width: 319px)",
        small: "(min-width: 320px) and (max-width: 499px)",
        medium_small: "(min-width: 500px) and (max-width: 749px)",
        medium: "(min-width: 750px) and (max-width: 999px)",
        medium_large: "(min-width: 1000px) and (max-width: 1499px)",
        large: "(min-width: 1500px) and (max-width: 2499px)",
        very_large: "(min-width: 2500px)",
    };

    let preferredSize = null;

    // Determine the preferred size based on the media query
    if (typeof window !== 'undefined' && window.matchMedia) {
        // Determine the preferred size based on the media query
        for (const size in mediaQueries) {
            if (window.matchMedia(mediaQueries[size]).matches) {
                preferredSize = size;
                break;
            }
        }
    } else {
        // Set a default size if window is not available
        preferredSize = 'medium';
    }
    // If a preferred size is determined, attempt to find a media file that matches
    if (preferredSize) {
        // Flatten the array of arrays to make it easier to search through all media items
        const flatMedia = media.flat();

        const file = flatMedia.find(file => file.size === preferredSize);
        if (file) return file;
    }

    // Fallback logic: return the smallest available size or a specific default if no size matches
    // Flatten the media array for the fallback scenario as well
    const flatMedia = media.flat();

    return flatMedia.find(file => file.size) || null;
};

Create a React component

Create a React component in src/components/DynamicImage.jsx with the following code:  

import React, { useState, useEffect } from 'react';
import { getMediaFile } from './content';

function DynamicImage({ media, id }) {
    const [imageSrc, setImageSrc] = useState('');
    const [imageAlt, setImageAlt] = useState('');

    useEffect(() => {
        const mediaFile = getMediaFile(media);
        if (mediaFile) {
            setImageSrc(`//${mediaFile.link}`);
            setImageAlt(mediaFile.description || 'Dynamic Image');
        }
    }, [media]); // Re-run effect if media prop changes

    return (
        <img id={id} width="1020" height="510" src={imageSrc} alt={imageAlt} />
    );
}

export default DynamicImage;

Use the DynamicImage component

  Use the DynamicImage component in your BlogPost.astro (or whatever Astro component that needs to display images):  

---
import type { CollectionEntry } from 'astro:content';
import BaseHead from '../components/BaseHead.astro';
import Header from '../components/Header.astro';
import Footer from '../components/Footer.astro';
import FormattedDate from '../components/FormattedDate.astro';
import DynamicImage from '../components/DynamicImage';

type Props = CollectionEntry<'compositions'>['data'];

const { title, description, pubDate, updatedDate, media } = Astro.props;
---

<html lang="en">
    <head>
        <BaseHead title={title} description={description} />
        <style>
            main {
                width: calc(100% - 2em);
                max-width: 100%;
                margin: 0;
            }
            .hero-image {
                width: 100%;
            }
            .hero-image img {
                display: block;
                margin: 0 auto;
                border-radius: 12px;
                box-shadow: var(--box-shadow);
            }
            .prose {
                width: 720px;
                max-width: calc(100% - 2em);
                margin: auto;
                padding: 1em;
                color: rgb(var(--gray-dark));
            }
            .title {
                margin-bottom: 1em;
                padding: 1em 0;
                text-align: center;
                line-height: 1;
            }
            .title h1 {
                margin: 0 0 0.5em 0;
            }
            .date {
                margin-bottom: 0.5em;
                color: rgb(var(--gray));
            }
            .last-updated-on {
                font-style: italic;
            }
        </style>
    </head>

    <body>
        <Header />
        <main>
            <article>
                <div class="hero-image">
                    {media && <DynamicImage client:load media={media} id="post-hero-image" />}
                </div>
                <div class="prose">
                    <div class="title">
                        <div class="date">
                            <FormattedDate date={pubDate} />
                            {updatedDate && (
                                <div class="last-updated-on">
                                    Last updated on <FormattedDate date={updatedDate} />
                                </div>
                            )}
                        </div>
                        <h1>{title}</h1>
                        <hr />
                    </div>
                    <slot />
                </div>
            </article>
        </main>
        <Footer />
    </body>
</html>

Important Note

  One of the reasons we use a React component for the portion that actually handles dynamic image size selection rather than using an Astro component is because we ran into an issue where using client:load with an Astro component would result in the following exception while building Astro 4.9.3:

 

[astro:build] The argument 'path' must be a string, Uint8Array, or URL without null bytes. Received '/Users/username/dev/project/\x00astro-entry:/Users/username/dev/project/src/components/tsconfig.json'