shadimage
channel3
componentimage-gallerysource: ok · refreshed

Image Gallery

Product image carousel with a synced thumbnail strip.

npx shadcn add "https://ui.trychannel3.com/r/image-gallery.json"
free

Where it goes

gallery

Interaction

carousel

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
iconslucide-react

Registry dependencies

carousel

Files1 · source cached 10/8/2026, 6:48:01 AM

registry/default/components/image-gallery.tsx
import * as React from "react";
import { ImageOff } from "lucide-react";
import type { ProductImage } from "@channel3/sdk/resources";

import { cn } from "@/lib/utils";
import {
  type CarouselApi,
  Carousel,
  CarouselContent,
  CarouselItem,
  CarouselNext,
  CarouselPrevious,
} from "@/components/ui/carousel";
import { productImageUrl } from "@/registry/default/lib/format";

function GalleryImage({
  image,
  priority = false,
  className,
}: {
  image: ProductImage;
  priority?: boolean;
  className?: string;
}) {
  const [failed, setFailed] = React.useState(false);
  const [loaded, setLoaded] = React.useState(false);
  const [useRaw, setUseRaw] = React.useState(false);
  const src = useRaw ? image.url : productImageUrl(image, { preferCleaned: true });
  // A server-rendered image can finish decoding before hydration, so `onLoad`
  // never fires on the client — reveal it on mount if it's already complete.
  const revealIfComplete = React.useCallback((node: HTMLImageElement | null) => {
    if (node?.complete) {
      setLoaded(true);
    }
  }, []);
  if (failed) {
    return (
      <div
        className={cn(
          "absolute inset-0 flex items-center justify-center text-muted-foreground",
          className,
        )}
      >
        <ImageOff className="size-8" aria-hidden />
      </div>
    );
  }
  return (
    <img
      src={src}
      alt={image.alt_text ?? ""}
      loading={priority ? "eager" : "lazy"}
      fetchPriority={priority ? "high" : undefined}
      decoding="async"
      className={cn(
        "absolute inset-0 size-full object-cover transition-opacity duration-300",
        loaded ? "opacity-100" : "opacity-0",
        className,
      )}
      ref={revealIfComplete}
      onLoad={() => setLoaded(true)}
      onError={() => {
        if (!useRaw && src !== image.url) {
          setUseRaw(true);
          setLoaded(false);
          return;
        }
        setFailed(true);
      }}
    />
  );
}

export interface ImageGalleryProps extends React.ComponentProps<"div"> {
  images: ReadonlyArray<ProductImage>;
  /**
   * Image shown in place of the active slide (e.g. a hovered variant swatch's
   * `thumbnail_url`). The carousel index is unchanged; clearing this restores
   * the slide.
   */
  previewSrc?: string | null;
}

export function ImageGallery({ images, previewSrc, className, ...props }: ImageGalleryProps) {
  const [api, setApi] = React.useState<CarouselApi>();
  const [selected, setSelected] = React.useState(0);
  const [brokenPreview, setBrokenPreview] = React.useState<string | null>(null);
  const activePreview = previewSrc && previewSrc !== brokenPreview ? previewSrc : null;

  React.useEffect(() => {
    if (!api) {
      return;
    }
    const sync = () => setSelected(api.selectedScrollSnap());
    sync();
    api.on("select", sync);
    api.on("reInit", sync);
    return () => {
      api.off("select", sync);
      api.off("reInit", sync);
    };
  }, [api]);

  // A new variant brings a new image set; start it on its first photo.
  const imagesKey = images.map((image) => image.url).join("\n");
  React.useEffect(() => {
    setSelected(0);
    api?.scrollTo(0, true);
  }, [api, imagesKey]);

  if (images.length === 0) {
    return (
      <div
        data-slot="image-gallery"
        className={cn(
          "flex aspect-square items-center justify-center rounded-lg bg-muted text-muted-foreground",
          className,
        )}
        {...props}
      >
        <ImageOff className="size-10" aria-hidden />
      </div>
    );
  }

  const multiple = images.length > 1;

  return (
    <div data-slot="image-gallery" className={cn("flex flex-col gap-2", className)} {...props}>
      <div className="relative">
        <Carousel setApi={setApi} className="w-full">
          <CarouselContent>
            {images.map((image, index) => {
              const showPreview = Boolean(activePreview) && index === selected;
              return (
                <CarouselItem key={`${image.url}-${index}`}>
                  <div className="relative overflow-hidden rounded-lg bg-muted">
                    {/* Invisible sizer: a square, plus the thumbnail strip's own
                        height when there's no strip — so a single image fills the
                        same footprint as a multi-image gallery. */}
                    <div aria-hidden className="invisible flex flex-col gap-2">
                      <div className="aspect-square" />
                      {multiple ? null : <div className="size-14" />}
                    </div>
                    <GalleryImage
                      image={image}
                      priority={index === 0}
                      className={showPreview ? "invisible" : undefined}
                    />
                    {showPreview ? (
                      <img
                        src={activePreview ?? undefined}
                        alt=""
                        className="absolute inset-0 size-full bg-muted object-cover"
                        onError={() => {
                          if (previewSrc) {
                            setBrokenPreview(previewSrc);
                          }
                        }}
                      />
                    ) : null}
                  </div>
                </CarouselItem>
              );
            })}
          </CarouselContent>
          {multiple ? (
            <>
              <CarouselPrevious className="left-2" />
              <CarouselNext className="right-2" />
            </>
          ) : null}
        </Carousel>
      </div>

      {multiple ? (
        <div className="flex items-start gap-2 overflow-x-auto scrollbar-none [&::-webkit-scrollbar]:hidden">
          {images.map((image, index) => (
            <button
              key={`thumb-${image.url}-${index}`}
              type="button"
              onClick={() => api?.scrollTo(index)}
              onMouseEnter={() => api?.scrollTo(index, true)}
              onFocus={() => api?.scrollTo(index, true)}
              aria-label={`View image ${index + 1}`}
              aria-current={index === selected}
              className={cn(
                "relative size-14 shrink-0 overflow-hidden rounded-md border bg-muted transition-opacity",
                index === selected
                  ? "border-ring ring-1 ring-ring"
                  : "opacity-60 hover:opacity-100",
              )}
            >
              <GalleryImage image={image} />
            </button>
          ))}
        </div>
      ) : null}
    </div>
  );
}

More from channel3

Browse all components in this library →