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
| Prop | Type | Default | Description |
|---|---|---|---|
| aria-label | string | Names the focusable region on HorizontalGallery, required. | |
| pace | number | 1 | Page scroll per pixel of track on HorizontalGallery. |
| src | string | An image on HorizontalGalleryItem. Leave it out for a text tile. | |
| alt | string | "" | Describes the image. |
| title | ReactNode | The caption under the image. | |
| meta | string | A short mono label at the end of the caption. | |
| children | ReactNode | Anything 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.