Horizontal Scroll Gallery
Pinned section where vertical scroll drives horizontal gallery position. Same pattern as Scroll-Linked Video Scrubber.
Component
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface HorizontalScrollGalleryProps {
children: React.ReactNode;
className?: string;
scroller?: React.RefObject<HTMLElement | null>;
scrollHeight?: string;
}
export function HorizontalScrollGallery({
children,
className,
scroller,
scrollHeight = "300%",
}: HorizontalScrollGalleryProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const [stickyHeight, setStickyHeight] = useState("100vh");
useEffect(() => {
const container = scroller?.current;
if (!container) {
setStickyHeight("100vh");
return;
}
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setStickyHeight(`${entry.contentRect.height}px`);
}
});
ro.observe(container);
setStickyHeight(`${container.clientHeight}px`);
return () => ro.disconnect();
}, [scroller]);
useEffect(() => {
const wrapper = wrapperRef.current;
const track = trackRef.current;
if (!(wrapper && track)) {
return;
}
const getContainer = () => scroller?.current ?? null;
const update = () => {
const container = getContainer();
const viewportTop = container
? container.getBoundingClientRect().top
: 0;
const viewportHeight = container
? container.clientHeight
: window.innerHeight;
const wrapperRect = wrapper.getBoundingClientRect();
const scrollableRange =
wrapperRect.height - viewportHeight;
if (scrollableRange <= 0) {
return;
}
const scrolled = viewportTop - wrapperRect.top;
const progress = Math.max(
0,
Math.min(1, scrolled / scrollableRange),
);
const trackWidth = track.scrollWidth;
const maxOffset =
trackWidth -
(container?.clientWidth ?? window.innerWidth);
if (maxOffset <= 0) {
return;
}
track.style.transform = `translateX(-${progress * maxOffset}px)`;
};
const scrollTarget = getContainer() ?? window;
scrollTarget.addEventListener("scroll", update, {
passive: true,
});
const ro = new ResizeObserver(update);
ro.observe(track);
update();
return () => {
scrollTarget.removeEventListener("scroll", update);
ro.disconnect();
};
}, [scroller]);
return (
<div
ref={wrapperRef}
className={cn("relative w-full", className)}
style={{ height: scrollHeight }}
>
<div
className="sticky top-0 w-full overflow-hidden"
style={{ height: stickyHeight }}
>
<div
ref={trackRef}
className="flex h-full w-max gap-4 will-change-transform"
>
{children}
</div>
</div>
</div>
);
}Installation
pnpm dlx shadcn@latest add "https://pulkitxm.com/components/horizontal-scroll-gallery.json"1. Copy the component file
"use client";
import { useEffect, useRef, useState } from "react";
import { cn } from "@/lib/utils";
interface HorizontalScrollGalleryProps {
children: React.ReactNode;
className?: string;
scroller?: React.RefObject<HTMLElement | null>;
scrollHeight?: string;
}
export function HorizontalScrollGallery({
children,
className,
scroller,
scrollHeight = "300%",
}: HorizontalScrollGalleryProps) {
const wrapperRef = useRef<HTMLDivElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const [stickyHeight, setStickyHeight] = useState("100vh");
useEffect(() => {
const container = scroller?.current;
if (!container) {
setStickyHeight("100vh");
return;
}
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (entry) {
setStickyHeight(`${entry.contentRect.height}px`);
}
});
ro.observe(container);
setStickyHeight(`${container.clientHeight}px`);
return () => ro.disconnect();
}, [scroller]);
useEffect(() => {
const wrapper = wrapperRef.current;
const track = trackRef.current;
if (!(wrapper && track)) {
return;
}
const getContainer = () => scroller?.current ?? null;
const update = () => {
const container = getContainer();
const viewportTop = container
? container.getBoundingClientRect().top
: 0;
const viewportHeight = container
? container.clientHeight
: window.innerHeight;
const wrapperRect = wrapper.getBoundingClientRect();
const scrollableRange =
wrapperRect.height - viewportHeight;
if (scrollableRange <= 0) {
return;
}
const scrolled = viewportTop - wrapperRect.top;
const progress = Math.max(
0,
Math.min(1, scrolled / scrollableRange),
);
const trackWidth = track.scrollWidth;
const maxOffset =
trackWidth -
(container?.clientWidth ?? window.innerWidth);
if (maxOffset <= 0) {
return;
}
track.style.transform = `translateX(-${progress * maxOffset}px)`;
};
const scrollTarget = getContainer() ?? window;
scrollTarget.addEventListener("scroll", update, {
passive: true,
});
const ro = new ResizeObserver(update);
ro.observe(track);
update();
return () => {
scrollTarget.removeEventListener("scroll", update);
ro.disconnect();
};
}, [scroller]);
return (
<div
ref={wrapperRef}
className={cn("relative w-full", className)}
style={{ height: scrollHeight }}
>
<div
className="sticky top-0 w-full overflow-hidden"
style={{ height: stickyHeight }}
>
<div
ref={trackRef}
className="flex h-full w-max gap-4 will-change-transform"
>
{children}
</div>
</div>
</div>
);
}2. Import and use
import { useRef } from "react";
import { HorizontalScrollGallery } from "@/components/horizontal-scroll-gallery";
export function GalleryDemo() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="h-screen overflow-y-auto"
>
<HorizontalScrollGallery scroller={containerRef}>
{/* Your gallery items */}
</HorizontalScrollGallery>
</div>
);
}Usage
Import
Add the import.
import { useRef } from "react";
import { HorizontalScrollGallery } from "@/components/horizontal-scroll-gallery";Use
Wrap gallery items. Pass scroller when inside a scroll container.
const containerRef = useRef<HTMLDivElement>(null);
<div ref={containerRef} className="h-96 overflow-y-auto">
<HorizontalScrollGallery scroller={containerRef}>
{items.map((item) => (
<Card key={item.id} {...item} />
))}
</HorizontalScrollGallery>
</div>;Guidelines
- Pass gallery items as children. They are laid out in a horizontal row.
- When inside a scrollable container, pass scroller={containerRef}.
- scrollHeight controls how much vertical scroll is needed to traverse the gallery.
Props
All props are optional unless marked required. Use these to customize every aspect of the component.
| Prop | Type | Default | Description |
|---|---|---|---|
| children | React.ReactNode | — | Gallery items (cards, images). Rendered in a horizontal row. |
| className | string | — | Additional CSS classes for the wrapper. |
| scroller | React.RefObject<HTMLElement | null> | — | Ref to the scroll container. Pass when inside overflow-y-auto. |
| scrollHeight | string | "300%" | Height of the vertical scroll runway. Larger = slower horizontal advance. |
Examples
Basic
Numbered cards. Scroll vertically to move horizontally through the gallery.
import { useRef } from "react";
import { HorizontalScrollGallery } from "@/components/horizontal-scroll-gallery";
export function GalleryDemo() {
const containerRef = useRef<HTMLDivElement>(null);
return (
<div
ref={containerRef}
className="h-72 overflow-y-auto"
>
<HorizontalScrollGallery
scroller={containerRef}
scrollHeight="400%"
>
{[1, 2, 3, 4, 5, 6].map((i) => (
<div
key={i}
className="flex h-40 w-56 shrink-0 items-center justify-center rounded-lg border border-border bg-muted text-2xl font-bold"
>
{i}
</div>
))}
</HorizontalScrollGallery>
</div>
);
}