Skip to main content
ASoc
Tutorial

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.

The ASoc Team11 min read

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.

Photo galleryProduct detail gallery
Image countDozens to thousandsThree to eight
Set is known at buildRarelyAlways
Slides must be crawlableNot reallyYes — it is product imagery
First imageOne of manyThe page's LCP element
Cover imageInterchangeableFixed: it is your og:image
Right techniqueVirtualize, paginateMount everything, translate
Failure modeSlow scrollImages 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-none honours 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.

<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-roledescription renames the role without inventing one. There is no role="carousel".
  • Descriptive alt on 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 an aria-label, and the SVG inside marked aria-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.

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

MistakeWhat happensFix
Rendering only the active slideProduct images missing from the HTMLMount all, translate the track
loading="lazy" on slide oneLCP delayed — ~1.7s in our own auditeager + fetchPriority="high"
No fetchPriority="low" on the restFour images compete for bandwidthStarve the off-screen slides
Missing width/heightLayout shift as each image landsExplicit dimensions + fixed aspect
Arrow keys on documentPage scrolling flips slidesHandle on the wrapper only
next % count for the indexLeft on slide 0 gives a negative indexDouble modulo
Off-screen slides focusableTab walks into invisible contentinert on inactive slides
Focus trapped in the carouselKeyboard users cannot reach the buy buttonNever trap in a non-modal widget
<div onClick> arrowsNo Enter/Space, no role, no focus ringReal <button type="button">
Carousel rendered for one imageControls with nothing to controlDegrade to a framed image
Reordering screenshots for looksog:image and schema silently changeSeparate cover and tour fields
Mixed aspect ratiosFrame resizes between slidesOne 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.

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.

Keep reading

Tutorial9 min read

Product Schema in Next.js: JSON-LD That Earns Rich Results

Product needs only one of offers, review or aggregateRating — so a truthful offer qualifies you without inventing reviews. The shape, the validation, the manual-action trap.

Read more
Tutorial12 min read

Product Variants in Next.js Without a Commerce Backend

Size and colour variants render fine from a typed file — until stock has to be authoritative. The option matrix, the URL-driven picker, the canonical, and where it breaks.

Read more
Tutorial8 min read

Next.js Redirects: Three APIs, and Why We Use Two of Them

Thirteen redirects, eight files, zero next.config.ts entries. Why every redirect() call here depends on auth state config-based redirects cannot see.

Read more