Blog//6 min read
Image gallery with lightbox in React using shared layout animation
Build a React image gallery lightbox where the photo grows out of the thumbnail you clicked, with keyboard control, a focus trap and thumbnails.
MSMarco Schulz, founder of Facade UI
Most lightboxes open the same way: the screen goes dark and a photo fades in at the center. It works, but it breaks the connection between the thumbnail you clicked and the image you are now looking at. The Image gallery lightbox in Facade does it differently. The photo you clicked grows from its spot in the grid into the full size view, and shrinks back into place when you close. That is a shared layout animation, and motion/react makes it a few lines. The rest of the component is the part nobody sees: keyboard control, a focus trap and a thumbnail strip that follows the current photo.
The grid
A listing gallery or a landscaping portfolio does not need a masonry layout. It needs a tidy grid that shows four or five photos and tells the visitor there are more. Facade's grid is two columns by two rows on phones and four columns on larger screens, with a fixed height so the layout does not shift while images load. The last tile carries a "+N photos" overlay, where N comes from totalCount if you pass it or from images.length if you do not.
Each tile is a <button>, not a <div> with a click handler. Buttons are focusable, respond to Enter and Space, and announce themselves. The aria-label on each one says "Open photo:" followed by the alt text, and the last tile says "View all N photos".
Images without a src render an illustrated placeholder, which is handy for a demo and for the moment before a client sends real photos.
layoutId transitions
The shared layout trick needs two motion.div elements with the same layoutId, one in the grid and one in the lightbox. When the first unmounts and the second mounts inside an AnimatePresence, motion measures both and animates position, size and border radius from one to the other.
// in the grid tile
<motion.div layoutId={`${uid}-photo-${i}`} transition={{ duration: 0.45, ease }}>
<Picture image={image} index={i} />
</motion.div>
// in the lightbox
<motion.div layoutId={`${uid}-photo-${origin}`}>
<Picture image={images[current]} index={current} large />
</motion.div>Two things to get right. First, the uid prefix. If two galleries sit on the same page with photo index 0, motion cannot tell them apart. React.useId() gives each gallery instance its own prefix. Second, origin is the index that was clicked, and it stays fixed while the lightbox is open. The visitor can arrow through ten more photos, and when they close, the image animates back into the tile they started from. If you used current instead, closing would try to animate into a tile that might not exist in the grid.
Inside the lightbox the photo changes with a second AnimatePresence in popLayout mode with a custom direction, so the next photo slides in from the right and the previous from the left. Reduced motion shortens both transitions to zero.
Keyboard and focus trap
A lightbox is a modal dialog. The container gets role="dialog", aria-modal="true" and an aria-label. Then the keyboard rules:
- Escape closes.
- ArrowRight and ArrowLeft move between photos.
- Tab and Shift+Tab stay inside the dialog. The handler collects the focusable elements, and when focus would leave the last one it wraps to the first, and the reverse.
- When the lightbox opens, focus moves to the close button. When it closes, focus returns to the tile that opened it, with
preventScrollso the page does not jump.
The page behind the dialog should not scroll while it is open. Setting document.body.style.overflow = "hidden" on open and restoring the previous value on close handles it. The photo counter at the top uses aria-live="polite", so a screen reader hears "3 of 12" as the visitor moves.
None of this is exotic. It is the ARIA dialog pattern, applied. But it is the part most gallery libraries ship half of, and the half they skip is the half that fails an accessibility audit.
Thumbnails
Under the main photo sits a strip of thumbnails. Each is a button with the same aria-label pattern, the active one at full opacity and the rest dimmed. When current changes, the active thumbnail scrolls into view with scrollIntoView({ inline: "center", block: "nearest" }), smooth unless reduced motion is on. The strip hides its scrollbar with scrollbar-width: none, since the thumbnails themselves show where you are.
On a phone, the strip is also the fastest way to jump to photo 9 of 12 without tapping the arrow eight times.
Preloading neighbors
The shared layout animation looks broken if the next photo has not loaded when it slides in. The grid photos are already in the browser cache because they were rendered on the page. The ones past the grid are not. Preload the neighbors of the current photo as soon as the lightbox opens:
React.useEffect(() => {
if (!open) return;
for (const i of [current - 1, current + 1]) {
const src = images[(i + images.length) % images.length]?.src;
if (src) {
const img = new Image();
img.src = src;
}
}
}, [open, current, images]);Creating an Image and setting src is enough; the browser fetches it and keeps it in cache. If you render through next/image, use priority on the current photo and let the neighbors load this way.
Install and use
The gallery is a Pro component. It installs from the private registry once your license key is set up in components.json (see the Pro setup):
npx shadcn@latest add @facade/image-gallery-lightboximport { ImageGalleryLightbox, type GalleryImage } from "@/components/facade/image-gallery-lightbox";
const photos: GalleryImage[] = [
{ src: "/projects/maple-st/01.jpg", alt: "Front yard with new flagstone path", label: "Front yard" },
{ src: "/projects/maple-st/02.jpg", alt: "Back patio with built-in fire pit", label: "Patio" },
{ src: "/projects/maple-st/03.jpg", alt: "Raised cedar planter beds", label: "Garden" },
{ src: "/projects/maple-st/04.jpg", alt: "Drip irrigation controller", label: "Irrigation" },
{ src: "/projects/maple-st/05.jpg", alt: "Evening view with path lighting", label: "Lighting" },
];
<ImageGalleryLightbox images={photos} totalCount={18} />label is the short caption shown in the lightbox. alt is the long description for screen readers and search engines. Write both. On a landscaping site I put one gallery per project page, right after the before and after slider, and use totalCount to show the real photo count while only loading the first five into the grid.
FAQ
Can I use next/image inside the gallery?
Yes. The gallery renders a small Picture component for each GalleryImage, and that component is yours to edit after installation. Swap the img for next/image with fill and sizes, and keep the motion.div wrapper so the shared layout animation still measures the right box.
How many photos should go in the grid?
Five is enough. The grid shows four tiles with the fifth carrying the "+N photos" overlay. Everything past the grid loads only when the lightbox opens, which keeps the page light. Core Web Vitals treats the largest image above the fold as the Largest Contentful Paint candidate, and Google's documented threshold for a good LCP is 2.5 seconds, so the first grid photo should be small and optimized.
Does the lightbox work with swipe on mobile?
The arrows and the thumbnail strip work with touch. Swipe gestures are not built in, since a horizontal swipe on a full width photo conflicts with the browser's own back gesture on some phones. If you want it, add a drag="x" handler on the photo motion.div and call next or previous when the drag offset passes a threshold.