A Product Image Gallery in Next.js That Google Can Actually See
Virtualizing a four-slide carousel removes three product images from the HTML. Mount every slide, starve the off-screen ones, and the ARIA that a carousel actually needs.
A product gallery is not a photo gallery. Keep every slide mounted in a translated track so crawlers see all of the product's imagery, give the first slide the load priority and starve the rest, and drive it with a real <button> plus arrow keys scoped to the component. Swapping one <img> on click is the version that costs you image indexing.
Search for a Next.js image gallery and you get lightboxes, masonry grids and CDN widgets. A commerce detail page has different constraints, and three of them are load-bearing.
What makes a product gallery different
| Photo gallery | Product detail gallery | |
|---|---|---|
| Image count | Dozens to thousands | Three to eight |
| Set is known at build | Rarely | Always |
| Slides must be crawlable | Not really | Yes — it is product imagery |
| First image | One of many | The page's LCP element |
| Cover image | Interchangeable | Fixed: it is your og:image |
| Right technique | Virtualize, paginate | Mount everything, translate |
| Failure mode | Slow scroll | Images missing from Google |
The last row is the one that bites. Virtualizing a four-slide carousel saves nothing and removes three product images from the HTML — the exact imagery you want in image search and in the Product schema.
Mount every slide, move a transform
The whole architecture is one decision: render all slides into a flex track and translate the track.
<div
className="flex transition-transform duration-500 ease-out motion-reduce:transition-none"
ref={trackRef}
style={{ transform: `translateX(-${index * 100}%)` }}
>
{images.map((src, i) => (
<div className="w-full shrink-0" key={src} role="group">
<img src={src} alt={`${name} screenshot ${i + 1} of ${count}`} />
</div>
))}
</div>
Three consequences, all of them good:
- The HTML contains every image. Crawlers, the accessibility tree and find-in-page all see the full set.
- Changing slides costs a compositor transform, not a network request. No spinner, no layout shift, no flash of the previous image.
motion-reduce:transition-nonehonours the reduced-motion preference for free, because the animation is one CSS property.
Compare with the two patterns that look simpler:
// Conditional render — three of four images never reach the HTML.
<img src={images[index]} />
// Swap the src — same problem, plus a network request per click
// and a visible blank frame while it loads.
<img src={current} onClick={() => setCurrent(next)} />
Both are fine for a lightbox opened on demand. Neither belongs on a page whose job is to get product images indexed.
The first slide is your LCP element, and the others are competing with it
This is the part that separates a gallery that scores well from one that does not. All four slides are in the HTML, so by default the browser fetches four images at once — and the one the user can actually see gets a quarter of the bandwidth.
Fix it on the image tags:
<img
alt={`${name} screenshot ${i + 1} of ${count}`}
className="aspect-[16/9] w-full object-cover object-top"
decoding="async"
// The first slide is the page's LCP element; the rest must not
// compete with it for bandwidth on load.
fetchPriority={i === 0 ? "high" : "low"}
height={900}
loading={i === 0 ? "eager" : "lazy"}
src={src}
width={1600}
/>
loading="lazy" on the first slide is a worse bug than it looks. Our own product grid carried it and paid roughly 1.7 seconds of load delay on the LCP image, because a lazy image is not even discovered until layout runs. Whatever renders first on your page must be eager with fetchPriority="high"; everything else must be the opposite.
The explicit width and height are not optional either. Combined with a fixed aspect-[16/9], they reserve the box before the bytes arrive, so the page never jumps.
Keyboard, and the mistake of a global listener
Arrow keys should move the carousel — but only while the carousel has focus:
// Arrow keys drive the carousel only while it holds focus — a global
// listener would hijack the arrow keys for the whole page.
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "ArrowRight") {
e.preventDefault();
go(index + 1);
} else if (e.key === "ArrowLeft") {
e.preventDefault();
go(index - 1);
}
};
Attach it to the wrapper, not to document. A document-level arrow handler means a user scrolling the page with the keyboard silently flips through screenshots instead — a bug that never appears in a click-through QA pass.
The index wraps, so the arrows never dead-end:
const go = useCallback(
(next: number) => setIndex(((next % count) + count) % count),
[count],
);
The double modulo is doing real work: plain next % count returns a negative index when you press Left on slide zero.
The ARIA that a carousel actually needs
<div
aria-label={`${name} screenshots`}
aria-roledescription="carousel"
role="region"
tabIndex={-1}
>
Each slide is a labelled group:
<div aria-label={`${i + 1} of ${count}`} aria-roledescription="slide" role="group">
Four rules behind that markup:
role="region"with a label, so the gallery is reachable as a landmark rather than an unnamed div.aria-roledescriptionrenames the role without inventing one. There is norole="carousel".- Descriptive
alton every slide, including position — "screenshot 2 of 4" tells a screen reader user where they are when nothing else does. - Real
<button>elements for the arrows, each with anaria-label, and the SVG inside markedaria-hidden="true"so the accessible name does not become "Next screenshot, image".
Off-screen slides should not hold focus
A slide translated out of view still contains focusable content. Mark the inactive ones inert:
useEffect(() => {
const slides = trackRef.current?.children;
if (!slides) return;
for (let i = 0; i < slides.length; i++) {
const el = slides[i] as HTMLElement;
if (i === index) el.removeAttribute("inert");
else el.setAttribute("inert", "");
}
}, [index]);
Applied imperatively rather than as a prop because React still types inert loosely across renderers. Without it, Tab walks into images the user cannot see and the page appears to scroll sideways on its own.
Note what this is not: a focus trap. The user must be able to Tab straight out of the gallery into the buy button. Trapping focus in a non-modal widget is a WCAG 2.1.2 failure.
Thumbnails beat dots when slides differ
Dots are right when slides are variations of one thing. When each slide is a different page of the product, a strip of small crops is a map:
<button
aria-current={i === index}
aria-label={`Show screenshot ${i + 1} of ${count}`}
className={i === index ? "border-primary" : "border-transparent opacity-60"}
onClick={() => go(i)}
type="button"
>
<img alt="" src={src} loading="lazy" width={160} height={90} />
</button>
alt="" on the thumbnail is deliberate — the button already has an accessible name, and a described thumbnail would announce the same image twice. aria-current is what communicates the selected state to assistive tech; the coloured border only communicates it to people who can see it.
Degrade when there is nothing to control
if (count <= 1) {
return (
<div className={frame}>
<img src={images[0]} alt={`${name} screenshot`} width={1600} height={900} />
</div>
);
}
A single-image product should render a framed image, not a carousel with disabled arrows and one thumbnail. Every catalog eventually contains one.
Which images go in the gallery — and which must never move
Keep two separate fields, because they serve different consumers:
/** SEO surface. [0] is the cover every card, og:image and thumbnail
* resolves to — never reorder it. Then its 1:1 / 4:3 / 16:9 crops
* for the Product schema's `image` array. */
screenshots: string[];
/** Detail-page carousel — ~4 all-16:9 slides touring different pages
* of the product. No SEO duty, so these are stored as WebP outright. */
gallery?: string[];
Merging them is tempting and costs you the day someone reorders the array for a better-looking carousel and silently changes every social preview and the first image in your Product schema. Keeping all gallery slides at one aspect ratio matters too: mixed ratios make the frame resize as you page through it.
Sizing without a request-time optimizer
A gallery is where image weight concentrates, and a fixed set of images does not need an optimizer running per request. Build the derivatives once — a 1600w WebP for the detail frame, a smaller one for cards — and point plain <img> tags at them. Our cover images went from 255 KiB to 33 KiB that way, with no image-optimization spend at all. The full pipeline is its own post; the relevant rule here is simply that the carousel should never be handed the original.
Mistakes and how they show up
| Mistake | What happens | Fix |
|---|---|---|
| Rendering only the active slide | Product images missing from the HTML | Mount all, translate the track |
loading="lazy" on slide one | LCP delayed — ~1.7s in our own audit | eager + fetchPriority="high" |
No fetchPriority="low" on the rest | Four images compete for bandwidth | Starve the off-screen slides |
Missing width/height | Layout shift as each image lands | Explicit dimensions + fixed aspect |
Arrow keys on document | Page scrolling flips slides | Handle on the wrapper only |
next % count for the index | Left on slide 0 gives a negative index | Double modulo |
| Off-screen slides focusable | Tab walks into invisible content | inert on inactive slides |
| Focus trapped in the carousel | Keyboard users cannot reach the buy button | Never trap in a non-modal widget |
<div onClick> arrows | No Enter/Space, no role, no focus ring | Real <button type="button"> |
| Carousel rendered for one image | Controls with nothing to control | Degrade to a framed image |
Reordering screenshots for looks | og:image and schema silently change | Separate cover and tour fields |
| Mixed aspect ratios | Frame resizes between slides | One ratio for every slide |
Frequently asked questions
Should I use Embla, Swiper or Keen Slider instead? For a four-slide product gallery, no. The libraries earn their bundle on drag physics, infinite loops, autoplay and virtualization — none of which a product detail page needs, and one of which (virtualization) actively removes imagery you want indexed. On a page that already ships a slider library for something else, reuse it. Do not add one for this.
Do hidden carousel slides get indexed? Slides that are present in the HTML and hidden by CSS transform are crawled. Slides that are conditionally rendered do not exist to a crawler at all. That distinction is the entire reason for the translated track, and it is the same rule that governs navigation hidden behind a disclosure.
Is autoplay worth adding? Almost never on a product page. It moves the thing the shopper is looking at, it requires a pause control to meet WCAG 2.2.2, and it competes with the buy button for attention. If you must, pause on hover and focus, and stop permanently after the first user interaction.
How many slides should a product have? Four is a good default: the cover plus three views that show something new. Beyond about eight, thumbnails stop fitting on mobile and shoppers stop paging. More images are better placed in a lightbox opened deliberately, where virtualization is finally the correct tool.
What about swipe on touch devices? Shoppers expect it. Add it with a pointer-event handler on the track and a distance threshold; keep the arrows and thumbnails so keyboard and mouse users are not left with a gesture-only interface. Swipe is an enhancement, never the only way through the gallery.
Storefront templates with the gallery already built
The gallery is one of the few commerce components where the details above decide whether the product looks credible, and it is finished in a template.
ASoc Drape is a fashion and lifestyle storefront with new-season collections and a lookbook — the most gallery-dependent category there is. ASoc Satchel sells made-to-order leather goods with a workshop story, where detail shots do the selling. ASoc Willow is a furniture and décor storefront whose lounge chairs, sofas and lighting all need multiple views before anyone buys.
Browse the full set of Next.js shop templates or the Tailwind shop templates. For the rest of a product page — options, stock and the canonical URL — see product variants without a commerce backend.
