Build an image gallery with Astro and React

Table of contents
- Introduction
- What we will be building
- Image - server component, client component, slot, props
- API route vs import.meta.glob()
- Code structure
- Static generation, include image urls and map() on the client
- Responsive, optimized images - getImage() and <img srcset sizes />
- Blur preloader, CSS transition
- Infinite scroll
- Cumulative layout shift
- Lightbox with Photoswipe
- Completed code and demo
- Outro
- References

Introduction
I wanted to have a simple, Instagram-like, scroll paginated gallery page on the website where I could share my everyday photos. Initially I implemented it using benhowell/react-grid-gallery package for gallery, and frontend-collective/react-image-lightbox for lightbox component. It worked ok, but since those are a bit legacy packages I was unable to upgrade to React 19, it loaded all images at once without scroll pagination and Lighthouse score wasn't so great.
You can see that implementation if you navigate back in Git history e0165b:
# in git history navigate back to the old gallery commit
git checkout e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae
# preview
yarn clean && yarn install && yarn dev
I decided to reimplement it, did a quick research and decided to make my own gallery component and use dimsemenov/photoswipe package for lightbox. And that's how this article got created, while implementing I took notes about the most important and interesting parts from the process. Look at it as not necessarily the absolute best way to make image gallery with Astro and React but as one of the ways that is proven in practice and works well.
What we will be building
Demo: https://nemanjamitic.com/gallery
Github repository: https://github.com/nemanjam/nemanjam.github.io
Image - server component, client component, slot, props
This is the first dilemma and initial decision that affects all the future code that we write. Since this is a static website example we are naturally inclined to pre-render everything we can at build time, but can this work for images too?
Astro provides <Image />
component and it's a server component like any other Astro component. It is clear that we will need onLoad
, onClick
events on a image and events aren't possible on a server component. Yes, but maybe we can use client component wrapper and pass Astro <Image />
component as a slot so we can have best from both - Astro component for image optimization and a <div />
for events, could this work?
Not really, for any preload effects onLoad
event needs to be on the <img />
tag, but more important is that we can't pass any client props to the slot <Image />
component, we can generate only a single instance at build time. For any props values we would need to pregenerate separate image HTML which in this case is highly impractical.
Conclusion: We will use a React client component that supports interactivity and Astro getImage()
function to optimize the images.
API route vs import.meta.glob()
We want to stick to a static website, for performance reasons and convenient deployments. What way should we use to pass the image urls to the client? We could make a static API endpoint that serves JSON array. We could even make an parametrized API endpoint that serves optimized images.
Right away, why having an extra HTTP call for JSON on client when we can pregenerate image urls at build time, it's not what we want.
For a static API endpoint, since it's static we would need to pre-render all params at build time, so we could do http://localhost/api/gallery/xl/image1.webp
but not http://localhost/api/gallery/300x200/image1.webp
and http://localhost/api/gallery/301x200/image1.webp
, for that we would need to enable Astro server side rending and have Node.js runtime in production.
If we log a src
attribute of an imported image in dev and prod mode we will see something like this:
// in dev
http://localhost:3000/_image?href=/@fs/home/username/Desktop/nemanjam.github.io/src/assets/images/all-images/morning1.jpg?origWidth=4608&origHeight=2592&origFormat=jpg&w=1280&h=720&f=webp
// in prod
http://localhost:3000/_astro/morning1.CEdGhKb3_nVk9T.webp
So Astro is already serving images for us, with a dedicated API endpoint we would just accomplish human friendly url rewriting, that could be useful only if some external service fetches those images, which we don't have here.
Conclusion: We will use import.meta.glob('/src/assets/images/all-images/*.jpg')
from Vite to import images as modules to obtain images at build time and pass them as props into the Gallery component.
The code is as follows src/libs/gallery/images.ts#L16:
// src/libs/gallery/images.ts
export const getGalleryImagesMetadata = (): ImageMetadata[] => {
const imageModules = import.meta.glob<{ default: ImageMetadata }>(
// can't be a variable
'/src/assets/images/all-images/*.jpg',
{ eager: true }
);
// convert map to array
const imagesMetadata = Object.keys(imageModules)
// filter excluded filenames
.filter((path) => !EXCLUDE_IMAGES.some((excludedFileName) => path.endsWith(excludedFileName)))
// return metadata array
.map((path) => imageModules[path].default);
return imagesMetadata;
};
Code structure
We will structure code like this: MDX (gallery.mdx) -> Astro component (Gallery.astro) -> React component (Gallery.jsx)
. The call stack is top-down, MDX is a declarative presentation layer, Astro component will resolve data - images, React component will handle events and define logic, it's the most complex layer.
Code (paraphrased):
// src/pages/gallery.mdx
<Gallery class="not-prose grow" />
// src/components/Gallery.astro
<ReactGallery client:only="react" images={randomizedGalleryImages} />
// src/components/react/Gallery.tsx
<div className="grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3">
{loadedImages.map((image) => (
<img {...imageProps} />
)}
</div>
Static generation, include image urls and map()
on the client
Again, interesting and important point that is easy to forget is that images.map()
needs to be in React component in order to have infinite scroll pagination. For that all image urls (and other props) need to be bundled and available on client, that is passed as props from Astro to the React component.
If we placed images.map()
in the Astro component we would we would have a single image list as is without any interactivity (pagination on scroll).
Reminder: Static "backend" runs only once - at build time. We have a Node.js runtime only in development, and not in production - in there we have just a webserver static folder for serving assets. Kind of obvious, but it can sometimes be overlooked when we decide whether to put certain code in a server or client component.
Responsive, optimized images - getImage()
and <img srcset sizes />
Astro provides getImage() function that we will use to optimize images and generate <img />
tag attributes for the client. It accepts the same arguments as the <Image />
component. Note, <img />
tag supports srcset
and sizes
attributes for responsive images which is sufficient for our use case. This time we don't need <picture />
support for different images (art direction) and different formats.
We will prepare different image presets (sizes) in src/libs/gallery/transform.ts#L7:
Note that only thumbnail uses responsive image, and lightbox uses a fixed size image since Photoswipe lightbox doesn't support responsive image (at least without a custom component).
// src/libs/gallery/transform.ts
// common props
const defaultAstroImageOptions = {
format: 'webp',
};
// thumbnail preset
export const thumbnailImageOptions = {
...IMAGE_SIZES.RESPONSIVE.GALLERY_THUMBNAIL,
};
// lightbox preset
export const lightboxImageOptions = {
...IMAGE_SIZES.FIXED.MDX_2XL_16_9,
};
// getImage() wrapper
export const getCustomImage = async (options: UnresolvedImageTransform): Promise<GetImageResult> =>
getImage({
...defaultAstroImageOptions,
...options,
});
After that we use getCustomImage()
to optimize gallery images that we previously loaded with import.meta.glob()
in src/libs/gallery/images.ts#L50:
// src/libs/gallery/images.ts
export const getGalleryImages = async (): Promise<GalleryImage[]> => {
const thumbnails = await getCustomImages(thumbnailImageOptions);
const lightBoxes = await getCustomImages(lightboxImageOptions);
const galleryImages = mergeArrays(thumbnails, lightBoxes).map(([thumbnail, lightbox]) => ({
thumbnail: imageResultToImageAttributes(thumbnail),
lightbox: imageResultToImageAttributes(lightbox),
}));
return galleryImages;
};
// select only needed attributes for the <img /> tag
export const imageResultToImageAttributes = (imageResult: GetImageResult): ImgTagAttributes => ({
src: imageResult.src,
srcSet: imageResult.srcSet?.attribute,
...imageResult.attributes,
});
Now we have the ready <img />
attributes (props) available to pass into the React gallery client component.
Interesting part is configuring <img />
sizes
(sizes
and widths
args in getImage()
) attribute for responsive images in src/constants/image.ts#L86:
// src/constants/image.ts
GALLERY_THUMBNAIL: {
widths: [TW_SCREENS.XS, TW_SCREENS.SM],
sizes: `(max-width: ${TW_SCREENS.SM}px) ${TW_SCREENS.SM}px, ${TW_SCREENS.XS}px`,
},
// actual <img /> tag attributes that are generated with the GALLERY_THUMBNAIL
<img
sizes="(max-width: 640px) 640px, 475px"
srcset="
/_astro/river16.CcFOUvED_Z2d5kbP.webp 475w,
/_astro/river16.CcFOUvED_Z16pb6L.webp 640w
"
src="/_astro/river16.CcFOUvED_Z1Dswo2.webp"
width="4000"
height="2252"
/>
// src/components/react/Gallery.tsx
<div
id={GALLERY_ID}
className="pswp-gallery grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3"
>
...
</div>
If you are not familiar with defining responsive images, it's not that complicated as it seems. The code above basically says, bellow SM
screen breakpoint (640px
) use SM
size (width) (640px
) image, and if screen is wider than SM
use smaller XS
(475px
) image. Maybe unexpected to use smaller image for larger screen, but it makes sense when you look at responsive grid that is used for the gallery layout.
You can see in grid classes that bellow sm:
breakpoint image uses full width of the layout and above sm:
there are 2 images per row, above lg:
3 images per row, so it makes sense to use the larger image on smaller screens.
While configuring responsive images it's advisable to preview what is generated in the browser and ensure that result meets the expectation, we have sharp images at all resolutions and not too large image files.
Blur preloader, CSS transition
Large lightbox image will handle Photoswipe on its own, we won't interfere with it for now. But we can have some nice effect on thumbnail images on infinite scroll. They are already small enough to load fast so no need to use smaller resolution image for blur preloader, we can achieve the same effect with a simple CSS transition.
The following code does that src/components/react/Gallery.tsx#L132
// src/components/react/Gallery.tsx
const [loadedImages, setLoadedImages] = useState<GalleryImage[]>([]);
const isLoadingPageImages = useMemo(
() => !Object.values(loadedStates).every(Boolean),
[loadedStates, loadedImages.length]
);
useEffect(() => {
const callback: IntersectionObserverCallback = (entries) => {
// must wait here for images to load
if (!isEnd && !isLoadingPageImages && entries[0].isIntersecting) {
setPage((prevPage) => prevPage + 1);
}
};
// ...
// page dependency is important for initial load to work for all resolutions
}, [observerTarget, page, isEnd, isLoadingPageImages]);
const handleLoad = (src: string) => {
setLoadedStates((prev) => ({ ...prev, [src]: true }));
};
{loadedImages.map((image) => (
// ...
<img
{...image.thumbnail}
onLoad={() => handleLoad(image.thumbnail.src)}
alt={loadedStates[image.thumbnail.src] ? 'Gallery image' : ''}
className={cn(
'w-full transition-all duration-[2s] ease-in-out',
loadedStates[image.thumbnail.src]
? 'opacity-100 blur-0 grayscale-0'
: 'opacity-75 blur-sm grayscale'
)}
/>
))}
Note that we have a map()
call here and we are storing loading states for an array of images. This is because we want to have a smooth transition for the entire new page of images, not for each image separately because they will load randomly and that's less esthetic. Important part is isLoadingPageImages
variable, it is used to block loading a new page until all images from the previous page are loaded. This happens in the observer callback condition if (!isEnd && !isLoadingPageImages && entries[0].isIntersecting)
.
Another part is CSS transition, duration-[...]
should be picked so it takes more than actual thumbnail image loading time. For the transition effect, you can play around with opacity and Tailwind's filter classes and see what looks nicest to you.
Infinite scroll
We want to implement pagination through infinite scroll like e.g. Instagram. Obviously, for this, Gallery needs to be a client component and we will use IntersectionObserver to detect the bottom of the gallery and trigger loading a new page of images. For the observer we could use ready-made hooks from utility libraries like uidotdev/usehooks or streamich/react-use but lets go with our own custom implementation this time.
The code for this is in src/components/react/Gallery.tsx#L76:
// src/components/react/Gallery.tsx
// sets only page
useEffect(() => {
const callback: IntersectionObserverCallback = (entries) => {
// must wait here for images to load
if (!isEnd && !isLoadingPageImages && entries[0].isIntersecting) {
setPage((prevPage) => prevPage + 1);
}
};
const debouncedCallback = debounce(callback, OBSERVER_DEBOUNCE);
const options: IntersectionObserverInit = { threshold: 1 };
const observer = new IntersectionObserver(debouncedCallback, options);
const observerRef = observerTarget.current;
if (observerRef) observer.observe(observerRef);
return () => {
if (observerRef) observer.unobserve(observerRef);
};
// page dependency is important for initial load to work for all resolutions
}, [observerTarget, page, isEnd, isLoadingPageImages]);
There are 3 important parts in this code:
We need to include
page
state variable in theuseEffect
dependencies array because we want to trigger effect execution every time new page of images loads and height of gallery increases. Also note that we readpage
state value from the state setter callback argumentsetPage((prevPage) => prevPage + 1)
, that's why we must also listpage
inuseEffect
dependencies array.We need to be precise about when we are loading new page of images. Note this condition
if (!isEnd && !isLoadingPageImages && entries[0].isIntersecting)
, it practically means "load new page of images whenever 1. we haven't loaded all images AND 2. previous page of images is fully loaded - for esthetics AND 3. the gallery is scrolled to the bottom - main prerequisite.The observer
callback()
triggers quite often, so we need to limit the frequency by debouncing. NoteOBSERVER_DEBOUNCE
constant value needs to be fine tuned and validated through practical trial and error.
Another important and interesting part is detecting bottom of the page and displaying loader UI:
// src/components/react/Gallery.tsx
{/* control threshold with margin-top */}
{/* must be on top so loader doesn't affect it */}
<div ref={observerTarget} className="mt-0" />
<div
className={cn(
// duration-500 is related to OBSERVER_DEBOUNCE: 300
'flex items-center justify-center transition-all duration-500 ease-in-out',
shouldShowLoader ? 'min-h-48' : 'min-h-0'
)}
>
{shouldShowLoader && <PiSpinnerGapBold className="size-10 sm:size-12 animate-spin mt-4" />}
</div>
This can be tricky because they are circularly dependent - detection triggers showing loader and displaying loader affects position of detection <div ref={observerTarget}/>
. Another thing ot note is that detection div
has zero height and is placed either above or bellow the loader. It is important to be the above loader because we are interested in the bottom of the images, not the loader that will disappear from the UI in a few milliseconds anyway.
Another important part is controlling and fine-tuning the threshold of the observed element <div ref={observerTarget}/>
. We do this by adjusting the positioning with className="mt-0"
, controlling the observers callback execution frequency with OBSERVER_DEBOUNCE
, setting the transition timing for the loader element duration-500
, specifying how many images we load (number of rows in the gallery) using the pageSize
constant, and how many pages of images we load initially on the first screen initialPage
constant.
All of these parameters are connected together and you need to fine tune them for smooth infinite scroll experience. Also note that pageSize
and initialPage
constants are responsive and need to be defined for each breakpoint independently for full and ergonomic control.
You can see that in the constants file in src/constants/gallery.ts#L7
// src/constants/gallery.ts
export const GALLERY = {
GALLERY_ID: 'my-gallery',
// Todo: make it responsive
/** step. */
PAGE_SIZE: {
XS: 1,
SM: 2,
LG: 3,
},
/** page dependency in useEffect is more important. To load first screen quickly, set to 3 pages */
INITIAL_PAGE: {
XS: 3,
SM: 3,
LG: 3,
},
/** fine tuned for scroll */
OBSERVER_DEBOUNCE: 300,
} as const;
And the mapping to translate constants into usable pageSize
and initialPage
values are defined in utility functions in src/utils/gallery.ts#L8:
// src/utils/gallery.ts
const { PAGE_SIZE, INITIAL_PAGE } = GALLERY;
// related to gallery grid css
const breakpointToPageKey = {
XXS: 'XS',
XS: 'XS',
SM: 'SM',
MD: 'SM',
LG: 'LG',
XL: 'LG',
_2XL: 'LG',
} as const;
const defaultPageKey = 'LG' as const;
export const getPageSize = (breakpoint: Breakpoint): number => {
const key = breakpointToPageKey[breakpoint] ?? defaultPageKey;
const pageSize = PAGE_SIZE[key];
return pageSize;
};
export const getInitialPage = (breakpoint: Breakpoint): number => {
const key = breakpointToPageKey[breakpoint] ?? defaultPageKey;
const initialPage = INITIAL_PAGE[key];
return initialPage;
};
With this, we have a smooth scrolling experience on all screen sizes:
Also pay attention how we "fetch" a new page of images to update:
// src/components/react/Gallery.tsx
const fetchImagesUpToPage = (
images: GalleryImage[],
pageSize: number,
nextPage: number
): GalleryImage[] => {
const endIndex = nextPage * pageSize;
const isLastPage = endIndex >= images.length;
// for fetchPageImages pagination startIndex must use loadedImages and not all images and page
const selectedImages = images.slice(0, endIndex);
// load all images for last page
return !isLastPage ? sliceToModN(selectedImages, pageSize) : selectedImages;
};
// converts page to loaded images
useEffect(() => {
const upToPageImages = fetchImagesUpToPage(images, pageSize, page);
setLoadedImages(upToPageImages);
}, [page, images, pageSize]);
There are 2 important moments here:
Since we have a static website all image urls are already included and available on the client so we don't need to calculate the starting index and can simply use zero
images.slice(0, endIndex);
. Usually pagination implies a network and database calls that require bothstartIndex
andendIndex
, and if we went that path we would need to calculatestartIndex
by finding the last element of theloadedImages
state array in theimages
array and pass those as arguments.Since the
pageSize
constant is responsive it can change when e.g. user resizes the browser window, so we callsliceToModN(selectedImages, pageSize)
for evenly loaded new row. Note that we don't call this for the last page because, eventually, we want to load all images, and the correctloadedImages
array length is important for calculating theisEnd
variable.
Cumulative layout shift
Layout shift is important web vitals parameter and it's more challenging to optimize here since we are dealing with a dynamic client components. In the Gallery component we handle this by setting initialPage
constant to load enough images to fill the initial gallery screen.
// src/constants/gallery.ts
export const GALLERY = {
// ...
PAGE_SIZE: {
XS: 1,
SM: 2,
LG: 3,
},
INITIAL_PAGE: {
XS: 3,
SM: 3,
LG: 3,
},
// ...
} as const;
Another optimization we can do is to stretch the empty gallery container element with flex grow
. For that we need to modify the Page layout and pass the required Tailwind classes via the MDX frontmatter and articleClass
prop.
You can see that in src/layouts/Page.astro#L38:
// src/layouts/Page.astro
---
import Centered from '@/layouts/Centered.astro';
import { getOpenGraphImagePath } from '@/libs/api/open-graph/image-path';
import { cn } from '@/utils/styles';
export interface Content {
// ...
class?: string;
/** for flex flex-grow min-height to prevent layout shift for client components */
articleClass?: string;
}
// ...
const { title, description, class: className, articleClass } = content;
// ...
---
<Centered {metadata} class={cn(className)}>
{/* in general must not have flex, it will disable margin collapsing in MDX */}
<article class={cn('my-prose', articleClass)}>
<slot />
</article>
</Centered>
Flex class is passed from MDX frontmatter in src/pages/gallery.mdx#L7:
# src/pages/gallery.mdx
---
layout: '../layouts/Page.astro'
...
class: 'max-w-5xl'
articleClass: 'grow flex flex-col'
---
import Gallery from '../components/Gallery.astro';
# Gallery
<Gallery class="not-prose grow" />
This will reduce the shift of DOM elements size, it won't make it perfect like in fully static page but for our use case it's good enough.
Another point to make is that flex
container will disable margin collapsing which is important for proper vertical spacings in MDX generated HTML. So if you do that you will need to add an additional <div>
wrapper element without flex to re-enable proper margin collapsing.
Lighthouse score, old gallery:
Lighthouse score, new gallery:
Please ignore the "Accessibility" score above, since the accessibility attributes aren't yet tackled on the entire website.
Lightbox with Photoswipe
For previewing images in full screen lightbox we will use ready made library Photoswipe that looks solid, reliable and flexible. We will use a basic React example from the documentation.
This is the code src/components/react/Gallery.tsx#L98
// src/components/react/Gallery.tsx
// lightbox
useEffect(() => {
let lightbox: PhotoSwipeLightbox | null = new PhotoSwipeLightbox({
gallery: '#' + GALLERY_ID,
children: 'a',
pswpModule: () => import('photoswipe'),
});
lightbox.init();
return () => {
lightbox?.destroy();
lightbox = null;
};
}, []);
return (
<>
<div
id={GALLERY_ID}
className="pswp-gallery grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3"
>
{loadedImages.map((image) => (
<a
key={`${GALLERY_ID}--${image.lightbox.src}`}
// lightbox doesn't support responsive image
href={image.lightbox.src}
data-pswp-width={image.lightbox.width}
data-pswp-height={image.lightbox.height}
target="_blank"
rel="noreferrer"
>
<img
{...image.thumbnail}
// ...
/>
</a>
))}
</div>
{/* ... */}
<>
Note that for a simplicity sake we are using a simple fixed image and Photoswipe implements scale transition on its own. By default it uses a simple link <a href={image.lightbox.src}>
to load the <img src />
in the full page lightbox.
This is a tradeoff for simplicity. Loading a responsive image with srcset
would require integrating a custom component which could be a topic for another article. Another possible improvement is to enable closing lightbox on backdrop click on mobile which is not the case with the default config.
Lightbox image size is defined in src/libs/gallery/transform.ts#L24
// src/libs/gallery/transform.ts
export const lightboxImageOptions = {
...IMAGE_SIZES.FIXED.MDX_2XL_16_9,
};
// src/constants/image.ts
export const IMAGE_SIZES = {
FIXED: {
// ...
MDX_2XL_16_9: { width: TW_SCREENS._2XL, height: TW_SCREENS.HEIGHTS._2XL },
},
// ...
};
Completed code and demo
Demo: https://nemanjamitic.com/gallery
Github repository: https://github.com/nemanjam/nemanjam.github.io
The relevant files:
# new gallery https://github.com/nemanjam/nemanjam.github.io/tree/c1e105847d8e7b4ab4aaffad3078726c37f67528
git checkout c1e105847d8e7b4ab4aaffad3078726c37f67528
src/pages/gallery.mdx
src/components/Gallery.astro
src/components/react/Gallery.tsx
src/libs/gallery/images.ts
src/libs/gallery/transform.ts
src/utils/gallery.ts
src/constants/gallery.ts
src/constants/image.ts
src/components/react/hooks/useScrollDown.tsx
src/components/react/hooks/useWidth.tsx
# old gallery https://github.com/nemanjam/nemanjam.github.io/tree/e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae
git checkout e0165b295db2ccc72bbbb7be4bdd7eb48f7dedae
Outro
That was a pretty long read, thank you for your attention and dedication. Have you implemented an Astro image gallery yourself and used a different approach? Do you have suggestions for improvements or spotted anything incorrect? Don't hesitate to leave a comment below.
References
Astro gallery example, inspiration to take Photoswipe for a lightbox component https://github.com/EmaSuriano/astro-art-portfolio
Photoswipe documentation https://photoswipe.com/getting-started
Astro documentation, tutorial how to use
getImage()
function https://docs.astro.build/en/recipes/build-custom-img-component/Infinite scroll with React and IntersectionObserver tutorial https://blog.logrocket.com/react-infinite-scroll/ and Codesandbox example https://codesandbox.io/p/github/Elijah-trillionz/react-infinite-scroll/master
Images in Astro as client components, useful Reddit discussion https://www.reddit.com/r/astrojs/comments/1bia6lq/how_to_utilize_image_with_react_component
Subscribe to my newsletter
Read articles from Nemanja Mitic directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
