Horizontal Scroll Gallery for React
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
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. |
Accessibility
- The wrapper and track are presentational only; the component adds no interactive controls, ARIA roles, labels, or focusable elements of its own.
- Any keyboard operability and focus visibility depend entirely on the children you pass in, so links, buttons, and cards should remain natively focusable and reachable by tab order.
- Because horizontal position is driven by vertical scroll, keyboard-only users can still advance the gallery by scrolling the container, but there is no arrow-key or button affordance to step between items.
- The component does not check prefers-reduced-motion, so it should be guarded or disabled for users who request reduced motion since the scroll-linked horizontal travel can be disorienting.
Performance
- Only the CSS transform translateX is animated, which is GPU-composited and avoids layout or paint, making each update cheap.
- The track carries will-change transform to hint the browser to promote it to its own compositor layer.
- Scroll updates write the transform synchronously in a scroll handler registered with passive true, so they never block scrolling, though there is no requestAnimationFrame batching to coalesce bursts of events.
- A ResizeObserver on the track and another on the scroll container recompute sizing, and both observers plus the scroll listener are disconnected in effect cleanup to prevent leaks.
- There is no IntersectionObserver or visibility gating, so the handler keeps running while scrolling even when the gallery is off screen.
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>
);
}Related reading
- Moving Things Without Moving Them — Translating the track instead of reflowing it
- Smooth Scroll in React — Pairing horizontal galleries with smooth scrolling
Last updated on Jun 19