Skip to content
ARDENT / MOTION LAB
Library

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→