Galleries · Intermediate
Pinned Horizontal Gallery
The track's overflow width becomes the pinned scroll distance. Function-based values recompute on resize via invalidateOnRefresh.
Live preview
Scroll down; the pinned gallery travels sideways. The preview scrolls independently of this page.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Pinned Horizontal Gallery
* Vertical scrolling becomes a cinematic, edge-to-edge exhibition.
* Requires: gsap, Tailwind CSS with shadcn semantic tokens.
*/
import { useEffect, useRef, useState } from "react";
import { gsap } from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
function useSystemReduced() {
const [value, setValue] = useState(true);
useEffect(() => {
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
const update = () => setValue(query.matches);
update();
query.addEventListener("change", update);
return () => query.removeEventListener("change", update);
}, []);
return value;
}
const works = [
["atlas", "Atlas", "An exercise in perspective"],
["linen", "Linen", "The texture of everyday"],
["basalt", "Basalt", "Finding a lasting form"],
["meridian", "Meridian", "Somewhere in between"],
["tide", "Tide", "A quieter kind of energy"],
];
export default function HorizontalGallery({
reduced: reducedProp,
intensity = 1,
}: {
reduced?: boolean;
intensity?: number;
}) {
const systemReduced = useSystemReduced();
const reduced = reducedProp ?? systemReduced;
const scopeRef = useRef<HTMLElement>(null);
const stageRef = useRef<HTMLElement>(null);
const trackRef = useRef<HTMLDivElement>(null);
const strength = Math.min(1.5, Math.max(0.5, intensity));
useEffect(() => {
if (reduced || !scopeRef.current) return;
gsap.registerPlugin(ScrollTrigger);
const ctx = gsap.context(() => {
const stage = stageRef.current;
const track = trackRef.current;
if (!stage || !track) return;
const distance = () => Math.max(0, track.scrollWidth - stage.clientWidth);
gsap.to(track, {
x: () => -distance(),
ease: "none",
scrollTrigger: {
trigger: stage,
start: "top top",
end: () => `+=${Math.max(1, distance()) * strength}`,
pin: true,
scrub: 0.8 * strength,
invalidateOnRefresh: true,
},
});
}, scopeRef);
const timer = window.setTimeout(() => ScrollTrigger.refresh(), 250);
return () => {
window.clearTimeout(timer);
ctx.revert();
};
}, [reduced, strength]);
return (
<main ref={scopeRef} className="bg-background text-foreground">
<header className="mx-auto max-w-6xl px-6 py-[18vh]">
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Independent practice / selected work
</p>
<h1 className="mt-6 font-serif text-6xl tracking-tight md:text-9xl">A wider view.</h1>
<p className="mt-7 max-w-md text-lg text-muted-foreground">
Five studies in form, feeling, and the space between. Scroll to explore.
</p>
</header>
<section
ref={stageRef}
className={`flex flex-col justify-center ${reduced ? "py-12" : "min-h-[100svh] py-8"}`}
aria-label="Selected works"
>
<h2 className="px-6 pb-6 font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Selected works / 01 — 05
</h2>
<div className={reduced ? "" : "w-full overflow-hidden"}>
<div
ref={trackRef}
className={
reduced
? "mx-auto grid max-w-6xl gap-12 px-6 md:grid-cols-2"
: "flex w-max gap-6 px-6 md:gap-10 md:px-[8vw]"
}
>
{works.map(([seed, title, description], index) => (
<figure key={seed} className={reduced ? "min-w-0" : "w-[78vw] shrink-0 md:w-[42vw]"}>
<div className="overflow-hidden bg-muted">
<img
src={`https://picsum.photos/seed/ardent-${seed}/1000/1200`}
alt={`${title}: ${description}`}
className={
reduced
? "aspect-[4/5] w-full object-cover"
: "h-[48svh] w-full object-cover md:h-[58svh]"
}
width={1000}
height={1200}
onLoad={() => {
if (!reduced) ScrollTrigger.refresh();
}}
/>
</div>
<figcaption className="mt-4 flex items-start justify-between gap-4 border-t border-border pt-4">
<div>
<h3 className="font-serif text-3xl tracking-tight">{title}</h3>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
</div>
<span className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
0{index + 1}
</span>
</figcaption>
</figure>
))}
</div>
</div>
</section>
<footer className="px-6 py-[25vh] text-center font-serif text-4xl tracking-tight">
There is always more to see.
</footer>
</main>
);
}
Next patternSticky Image Sequence→