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→