Skip to content
ARDENT / MOTION LAB
Library

Parallax · Starter

Editorial Image Parallax

Each image is 130% of its frame height and travels from -15% to 15% while its frame crosses the viewport, creating depth without layout shift.

Live preview

Scroll through the photographs; each drifts inside its frame. The preview scrolls independently of this page.

Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
 * Editorial Image Parallax
 * Three oversized photographs drift through a spacious editorial layout.
 * 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 photographs = [
  {
    seed: "stillness",
    title: "A quieter perspective",
    layout: "md:col-span-7",
    ratio: "aspect-[4/5]",
  },
  {
    seed: "distance",
    title: "Room for distance",
    layout: "md:col-span-5 md:mt-48",
    ratio: "aspect-[3/4]",
  },
  {
    seed: "passage",
    title: "Between here and elsewhere",
    layout: "md:col-span-9 md:col-start-3",
    ratio: "aspect-[16/10]",
  },
];

export default function ImageParallax({
  reduced: reducedProp,
  intensity = 1,
}: {
  reduced?: boolean;
  intensity?: number;
}) {
  const systemReduced = useSystemReduced();
  const reduced = reducedProp ?? systemReduced;
  const scopeRef = useRef<HTMLElement>(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(() => {
      gsap.utils.toArray<HTMLElement>(".parallax-photo").forEach((image) => {
        gsap.fromTo(
          image,
          { yPercent: -7 * strength },
          {
            yPercent: 7 * strength,
            ease: "none",
            scrollTrigger: {
              trigger: image.parentElement,
              start: "top bottom",
              end: "bottom top",
              scrub: 0.5 * 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 pb-20 pt-[18vh]">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
          Field journal / visual studies
        </p>
        <h1 className="mt-6 max-w-4xl font-serif text-6xl tracking-tight md:text-9xl">
          The art of looking longer.
        </h1>
        <p className="mt-8 max-w-md text-lg leading-relaxed text-muted-foreground">
          Not every journey needs a destination. Sometimes a different pace is enough.
        </p>
      </header>
      <section
        className="mx-auto grid max-w-6xl gap-x-10 gap-y-20 px-6 md:grid-cols-12"
        aria-label="Photographic studies"
      >
        {photographs.map((photo, index) => (
          <figure key={photo.seed} className={photo.layout}>
            <div className={`relative overflow-hidden bg-muted ${photo.ratio}`}>
              <img
                src={`https://picsum.photos/seed/ardent-${photo.seed}/1200/1500`}
                alt={`Photographic study ${index + 1}: ${photo.title.toLowerCase()}`}
                className={
                  reduced
                    ? "h-full w-full object-cover"
                    : "parallax-photo absolute -top-[20%] h-[140%] w-full object-cover"
                }
                onLoad={() => {
                  if (!reduced) ScrollTrigger.refresh();
                }}
                loading="lazy"
                width={1200}
                height={1500}
              />
            </div>
            <figcaption className="mt-4 flex justify-between gap-4 border-t border-border pt-4">
              <span className="font-serif text-xl tracking-tight">{photo.title}</span>
              <span className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
                0{index + 1}
              </span>
            </figcaption>
          </figure>
        ))}
      </section>
      <footer className="mx-auto max-w-6xl px-6 py-[25vh]">
        <p className="max-w-3xl font-serif text-4xl tracking-tight md:text-6xl">
          Depth is a matter of pace.
        </p>
      </footer>
    </main>
  );
}
Next patternScroll-Scrub Line Draw→