Skip to content
goodtabs UI
BrowseBrowse components

Horizontal gallery

Vertical scroll moves a pinned horizontal track, a native snap scroller on phones.

Installs gsap from npm, under GSAP's own licence. No GSAP code ships in this registry.

npx shadcn@latest add https://ui.goodtabs.co/r/horizontal-gallery.json

With the @goodtabs namespace in components.json: npx shadcn@latest add @goodtabs/horizontal-gallery

A text tile and six framed studies. Scroll and the track slides past while each image pans inside its frame.

Usage

import { HorizontalGallery, HorizontalGalleryItem } from "@/components/ui/horizontal-gallery"

<HorizontalGallery aria-label="Recent work">
  <HorizontalGalleryItem src="/one.jpg" title="First piece" meta="2026" />
  <HorizontalGalleryItem src="/two.jpg" title="Second piece" meta="2026" />
</HorizontalGallery>

Props

PropTypeDefaultDescription
aria-labelstringNames the focusable region on HorizontalGallery, required.
pacenumber1Page scroll per pixel of track on HorizontalGallery.
srcstringAn image on HorizontalGalleryItem. Leave it out for a text tile.
altstring""Describes the image.
titleReactNodeThe caption under the image.
metastringA short mono label at the end of the caption.
childrenReactNodeAnything else in the item, such as the copy in a text tile.

Source

horizontal-gallery/horizontal-gallery.tsx
"use client";

import * as React from "react";
import { useGSAP } from "@gsap/react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";

import { cn } from "@/lib/utils";

gsap.registerPlugin(useGSAP, ScrollTrigger);

const STAGE = "(min-width: 768px) and (prefers-reduced-motion: no-preference)";

/** Each image hangs past its frame by 10% a side, so it can pan by 10% of the frame and never show an edge. */
const PAN = (10 / 120) * 100;

type HorizontalGalleryProps = Omit<React.ComponentProps<"div">, "aria-label"> & {
  /** Names the gallery for assistive tech. The scroller is a focusable region. */
  "aria-label": string;
  /** Page scroll per pixel of track. 1 moves the track one pixel for each pixel scrolled. */
  pace?: number;
};

/**
 * Vertical scroll drives a horizontal track while the section is pinned. The markup is a native
 * scroller with snap, so phones, reduced motion and no script all get the same real, swipeable list.
 */
function HorizontalGallery({ pace = 1, className, children, ...props }: HorizontalGalleryProps) {
  const root = React.useRef<HTMLDivElement>(null);
  const total = React.Children.count(children);

  useGSAP(
    () => {
      const media = gsap.matchMedia();
      media.add(STAGE, () => {
        const section = root.current;
        const track = section?.querySelector<HTMLElement>("[data-slot=horizontal-gallery-track]");
        if (!section || !track) return;
        section.setAttribute("data-staged", "");
        section.removeAttribute("tabindex");
        const travel = () => Math.max(0, track.scrollWidth - section.clientWidth);
        const range = () => `+=${travel() * pace}`;

        const slide = gsap.to(track, {
          x: () => -travel(),
          ease: "none",
          scrollTrigger: {
            trigger: section,
            pin: true,
            start: "top top",
            end: range,
            scrub: true,
            invalidateOnRefresh: true,
          },
        });
        gsap.fromTo(
          "[data-slot=horizontal-gallery-progress]",
          { scaleX: 1 / Math.max(total, 1) },
          {
            scaleX: 1,
            ease: "none",
            scrollTrigger: { trigger: section, start: "top top", end: range, scrub: true, invalidateOnRefresh: true },
          },
        );
        gsap.utils.toArray<HTMLElement>("[data-slot=horizontal-gallery-image]").forEach((image) => {
          gsap.fromTo(
            image,
            { xPercent: -PAN },
            {
              xPercent: PAN,
              ease: "none",
              scrollTrigger: {
                trigger: image.parentElement,
                containerAnimation: slide,
                start: "left right",
                end: "right left",
                scrub: true,
              },
            },
          );
        });

        // A focused link in an unseen card must bring its card into view through the page scroll,
        // because the track only moves when the page does.
        const reveal = (event: FocusEvent) => {
          const item = (event.target as HTMLElement).closest<HTMLElement>("[data-slot=horizontal-gallery-item]");
          const trigger = slide.scrollTrigger;
          if (!item || !trigger) return;
          const left = item.offsetLeft - (section.clientWidth - item.offsetWidth) / 2;
          const progress = gsap.utils.clamp(0, 1, left / (travel() || 1));
          window.scrollTo({ top: trigger.start + progress * (trigger.end - trigger.start), behavior: "instant" });
        };
        section.addEventListener("focusin", reveal);

        return () => {
          section.removeEventListener("focusin", reveal);
          section.removeAttribute("data-staged");
          section.setAttribute("tabindex", "0");
        };
      });
      return () => media.revert();
    },
    { scope: root, dependencies: [pace, total] },
  );

  return (
    <div
      ref={root}
      role="region"
      tabIndex={0}
      data-slot="horizontal-gallery"
      className={cn(
        "group/gallery relative flex min-h-dvh snap-x snap-mandatory scroll-px-6 [scrollbar-width:thin] items-center overflow-x-auto overflow-y-hidden overscroll-x-contain bg-background md:scroll-px-8",
        "data-staged:h-dvh data-staged:min-h-96 data-staged:snap-none data-staged:overflow-clip",
        className,
      )}
      {...props}
    >
      <ul
        data-slot="horizontal-gallery-track"
        className="relative m-0 flex w-max list-none items-stretch gap-4 p-0 px-6 md:gap-6 md:px-8"
      >
        {children}
      </ul>
      <div
        aria-hidden="true"
        className="absolute inset-x-8 bottom-8 hidden h-px bg-border group-data-staged/gallery:block"
      >
        <div data-slot="horizontal-gallery-progress" className="size-full origin-left bg-foreground" />
      </div>
    </div>
  );
}

type HorizontalGalleryItemProps = Omit<React.ComponentProps<"li">, "title"> & {
  /** An image for the frame. Leave it out and pass children for a text tile. */
  src?: string;
  /** Describes the image. Empty when the title already says it. */
  alt?: string;
  /** The caption under the image. */
  title?: React.ReactNode;
  /** A short mono label at the end of the caption. */
  meta?: string;
};

/** One stop on the track: a framed image with a caption, or anything you pass as children. */
function HorizontalGalleryItem({
  src,
  alt = "",
  title,
  meta,
  className,
  children,
  ...props
}: HorizontalGalleryItemProps) {
  return (
    <li
      data-slot="horizontal-gallery-item"
      className={cn("flex w-[78vw] shrink-0 snap-start flex-col justify-center md:w-auto", className)}
      {...props}
    >
      {src ? (
        <figure className="m-0">
          <div className="relative h-[64dvh] min-h-72 overflow-hidden rounded-lg border bg-muted md:aspect-3/2">
            {/* eslint-disable-next-line @next/next/no-img-element -- a registry item cannot assume next/image */}
            <img
              data-slot="horizontal-gallery-image"
              src={src}
              alt={alt}
              className="absolute inset-y-0 -left-[10%] h-full w-[120%] max-w-none object-cover"
            />
          </div>
          {title || meta ? (
            <figcaption className="mt-3 flex items-baseline justify-between gap-4">
              <span className="text-sm font-medium text-foreground">{title}</span>
              {meta ? (
                <span className="font-mono text-xs tracking-wide text-muted-foreground uppercase">{meta}</span>
              ) : null}
            </figcaption>
          ) : null}
        </figure>
      ) : null}
      {children}
    </li>
  );
}

export { HorizontalGallery, HorizontalGalleryItem };

For AI agents

Hand your agent this page, the markdown at /components/horizontal-gallery/prompt.md, or the Copy AI prompt button above.

When to use

  • A sequence of images that reads better as a strip than a grid
  • Portfolio work, case studies or a product tour with four to eight stops
  • Once on a page, as a change of direction

When not to use

  • For items people need to compare: a grid shows them all at once
  • More than twelve items, because the pin gets long
  • Inside a scaled or transformed parent, which breaks ScrollTrigger's measuring

Agent notes

  • GSAP installs from npm under GSAP's own licence. No GSAP code ships in this registry
  • aria-label is required: the root is a focusable region with tabIndex 0 so keyboards can scroll it
  • Below 768px, under reduced motion and with no script it is a native horizontal scroller with snap
  • While pinned, focus inside an unseen item scrolls the page to that item

The maths

  • Pin distance equals track width minus viewport width, so at pace 1 one pixel of wheel moves the track one pixel.
  • Images hang 10% past each side of the frame, so a pan of 10 / 120 = 8.33% of the image width is 10% of the frame and shows no edge.