HextaUI

Product Gallery

The image gallery of a product page, with thumbnails, zoom and a full-screen viewer. Handles variant images, swipe on touch, broken photos, video and loading.

Open in new tab

Product Gallery is the photo area of a product page. On desktop a thumbnail rail sits beside the main image, or a row sits under it, and a ring slides to the chosen thumbnail. Picking a thumbnail crossfades the photo in place instead of sliding through every shot in between, and it waits for the new photo to decode so the frame never flashes empty.

Hover a photo with a mouse and, after a short pause, it zooms to 2× around the cursor and swaps in the higher resolution zoomSrc. Click or press Enter to open the full-screen viewer. On touch the photos swipe with native scroll snapping that follows the finger, the thumbnails stay visible and a 2 / 5 counter sits in the corner. Tap opens the viewer and double-tap opens it zoomed in on that spot.

The viewer is a modal dialog. Arrows, swipes and thumbnails move between photos, and pinch, double-tap, click, the trackpad, the wheel and the + and − keys zoom and pan with soft edges. Swipe down to close, or press Escape, which first resets the zoom. Focus returns to the chosen thumbnail. With reduced motion, zoom and slide changes happen without animation.

Pass activeId to drive it from a variant picker: when it changes the gallery crossfades to that photo and scrolls the thumbnail into view. No photos shows a quiet placeholder, one photo drops the thumbnails and counter, a broken URL becomes a placeholder in the same frame, long sets scroll with a faded edge, and videos show their poster with a play button and never autoplay. ProductGallerySkeleton matches the layout while media loads.

  1. Obtenha o HextaUI Pro

    Um único pagamento libera todos os blocos, incluindo os futuros. Ver preços.

  2. Adicione o registro Pro ao components.json

    components.json
    {
      "registries": {
        "@hextaui-pro": {
          "url": "https://hextaui.com/r/pro/{name}.json",
          "headers": {
            "Authorization": "Bearer ${HEXTAUI_PRO_TOKEN}"
          }
        }
      }
    }
  3. Adicione seu token

    Crie um token na sua página de conta e coloque-o em .env.local como HEXTAUI_PRO_TOKEN.

  4. Adicione o bloco

    pnpm dlx shadcn@latest add @hextaui-pro/product-gallery

With VariantPicker

Keep the active image in state and point it at getVariantImage when a color changes. The gallery crossfades to that photo and scrolls its thumbnail into view, and the shopper can still browse the other photos.

"use client"

import * as React from "react"

import {
  findVariant,
  getInitialSelection,
  getVariantImage,
  type Product,
} from "../variant-picker/product"
import { VariantPicker } from "../variant-picker/variant-picker"
import { ProductGallery } from "@/components/blocks/product-gallery/product-gallery"

export function ProductPage({ product }: { product: Product }) {
  const [selection, setSelection] = React.useState(() =>
    getInitialSelection(product)
  )
  const [imageId, setImageId] = React.useState(
    () => getVariantImage(product, findVariant(product, selection))?.id
  )

  return (
    <div className="grid gap-10 md:grid-cols-2">
      <ProductGallery
        images={product.images ?? []}
        activeId={imageId}
        onActiveChange={setImageId}
      />
      <VariantPicker
        product={product}
        value={selection}
        onValueChange={(next, variant) => {
          setSelection(next)
          const closest =
            variant ??
            product.variants.find((item) =>
              Object.entries(next).every(
                ([name, value]) => item.selectedOptions[name] === value
              )
            )
          const image = getVariantImage(product, closest)
          if (image) setImageId(image.id)
        }}
      />
    </div>
  )
}

From Shopify media

Map the Storefront API media connection once. Images get a 1200px src, a 2400px zoomSrc and a 240px thumbnail through the CDN width param, and videos keep their preview image as the poster.

import type { ProductMedia } from "@/components/blocks/product-gallery/gallery"

type ShopifyImage = {
  url: string
  altText: string | null
  width: number | null
  height: number | null
}

type ShopifyMedia =
  | { __typename: "MediaImage"; id: string; image: ShopifyImage | null }
  | {
      __typename: "Video"
      id: string
      alt: string | null
      previewImage: ShopifyImage | null
      sources: { url: string; mimeType: string; height: number }[]
    }
  | { __typename: "ExternalVideo" | "Model3d"; id: string }

function sized(url: string, width: number) {
  const next = new URL(url)
  next.searchParams.set("width", String(width))
  return next.toString()
}

export function fromShopifyMedia(
  nodes: ShopifyMedia[],
  productTitle: string
): ProductMedia[] {
  return nodes.flatMap((node): ProductMedia[] => {
    if (node.__typename === "MediaImage" && node.image) {
      return [
        {
          id: node.id,
          src: sized(node.image.url, 1200),
          zoomSrc: sized(node.image.url, 2400),
          thumbnailSrc: sized(node.image.url, 240),
          alt: node.image.altText ?? productTitle,
          width: node.image.width ?? undefined,
          height: node.image.height ?? undefined,
        },
      ]
    }
    if (node.__typename === "Video" && node.previewImage) {
      const source =
        node.sources
          .filter((item) => item.mimeType === "video/mp4")
          .sort((a, b) => a.height - b.height)
          .find((item) => item.height >= 720) ?? node.sources[0]
      if (!source) return []
      return [
        {
          id: node.id,
          type: "video",
          src: source.url,
          poster: sized(node.previewImage.url, 1200),
          thumbnailSrc: sized(node.previewImage.url, 240),
          alt: node.alt ?? `${productTitle} video`,
        },
      ]
    }
    return []
  })
}

Standalone, with a skeleton

Show ProductGallerySkeleton while media loads, then the gallery. Square frames with fit="contain" suit cut-out shots of mixed shapes, and thumbnails can sit in a row under the image.

"use client"

import * as React from "react"

import type { ProductMedia } from "@/components/blocks/product-gallery/gallery"
import { ProductGallery, ProductGallerySkeleton } from "@/components/blocks/product-gallery/product-gallery"

export function ProductImages({ handle }: { handle: string }) {
  const [media, setMedia] = React.useState<ProductMedia[] | null>(null)

  React.useEffect(() => {
    let cancelled = false
    fetch(`/api/products/${handle}/media`)
      .then((response) => response.json() as Promise<ProductMedia[]>)
      .then((items) => {
        if (!cancelled) setMedia(items)
      })
      .catch(() => {
        if (!cancelled) setMedia([])
      })
    return () => {
      cancelled = true
    }
  }, [handle])

  if (media === null) return <ProductGallerySkeleton thumbnails="bottom" />

  return (
    <ProductGallery
      images={media}
      aspect={1}
      fit="contain"
      thumbnails="bottom"
    />
  )
}

Anatomia

As partes que você compõe, de fora para dentro.

ParteDescrição
ProductGalleryThe labelled carousel region: the main image, the thumbnails and the viewer.
ThumbnailsA tab list, one tab per photo or video, with a sliding ring on the chosen one.
SlidesOne tab panel per item. Only the active one is focusable and shown to screen readers.
ViewerA full-screen dialog with zoom, pan, swipe, arrows and its own thumbnails.
ProductGallerySkeletonA loading placeholder with the same frame and thumbnail geometry.

ProductGallery

Also takes section props.

PropTipoPadrão
imagesPhotos and videos in order. A photo is a ProductImage with optional zoomSrc and thumbnailSrc.
ProductMedia[]–
activeIdThe shown item when controlled, such as the image of the chosen variant.
string | null–
defaultActiveIdThe first item shown when uncontrolled. Defaults to the first one.
string–
onActiveChangeCalled when a thumbnail, swipe, arrow key or the viewer changes the shown item.
(id: string, media: ProductMedia) => void–
aspectWidth over height of the main frame and thumbnails.
number4 / 5
fitcontain letterboxes photos of mixed shapes on the muted background instead of cropping them.
"cover" | "contain""cover"
thumbnailsA rail beside the image, which becomes a row when the gallery is narrower than 448px, or always a row under it.
"start" | "bottom""start"
zoomHover zoom for mouse pointers. The viewer always zooms.
booleantrue
badgesShown over the top start corner of the main image, such as a New or Sale badge.
ReactNode–
labelsWords for the region, thumbnails, counter, announcements, viewer controls, placeholders and the zoom hint.
Partial<ProductGalleryLabels>–
PropTipoPadrão
idMatches ProductVariant.imageId for photos.
string–
srcThe photo, or the video file for type video.
string–
altDescribe the shot, such as Moss glazed vase with eucalyptus stems.
string–
zoomSrcA larger photo loaded when zoom starts.
string–
thumbnailSrcA small photo for the thumbnail. Defaults to src or the poster.
string–
typeVideos also need a poster.
"image" | "video""image"
posterThe still shown before a video plays.
string–

ProductGallerySkeleton

Also takes div props.

PropTipoPadrão
countHow many thumbnails to sketch. 1 hides them.
number5
aspectMatch the gallery.
number4 / 5
thumbnailsMatch the gallery.
"start" | "bottom""start"
TeclaAção
TabMoves to the main image, then into the thumbnails at the chosen one.
ArrowUpArrowDownArrowLeftArrowRightOn the thumbnails, moves to the previous or next one and shows it, wrapping at the ends. On the main image, shows the previous or next item.
HomeEndShows the first or last thumbnail.
EnterOn the main image, opens the viewer, or plays a video.
+-0In the viewer, zooms in, zooms out or resets.
EscapeIn the viewer, resets the zoom first, then closes and returns focus to the thumbnail.
  • The gallery follows the APG carousel pattern with tabs as the slide picker: a region named Product images with the carousel role description, a tab list of thumbnails named Image 3 of 7, and one tab panel per slide labelled 3 of 7. Selection follows focus, and only the chosen tab is in the tab order.
  • The visible image carries its own alt text. Hidden slides are removed from the accessibility tree, and the visual counter is hidden because the tabs already say the position.
  • A polite live region announces Image 3 of 7 with its alt text only when the shopper changes the image, never on load or when a variant picker moves it.
  • The viewer is a modal dialog with a title. Focus starts on the image and returns to the chosen thumbnail on close. Videos never autoplay, and play only after a press, with native controls.
  • Zoom and slides animate only without reduced motion. Focus rings use the focus ring token, the chosen thumbnail gets an outline in forced colors, and touch thumbnails are at least 44px.

Construído com

Os componentes gratuitos do HextaUI de que Product Gallery é feito. Cada um é instalado separadamente.

Código

6 arquivos, adicionados a components/blocks/product-gallery.