Image Reveals · Intermediate
Scroll Reveal Mask
clip-path inset with rounded corners is scrubbed from 30% to 0 while the image scales down, and the title lifts away.
Live preview
Scroll into the stage; the image window opens to full frame. The preview scrolls independently of this page.
Copy-ready · type-checkedThe preview above renders this exact file. Reduced-motion fallback included.
/**
* Reveal Mask
* A photographic aperture opens into a full cinematic frame.
* 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;
}
export default function RevealMask({
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(() => {
const timeline = gsap.timeline({
scrollTrigger: {
trigger: ".reveal-stage",
start: "top top",
end: "+=160%",
pin: true,
scrub: 0.7 * strength,
invalidateOnRefresh: true,
},
});
timeline.fromTo(
".reveal-window",
{ clipPath: `inset(${18 * strength}% ${14 * strength}% round 2px)` },
{ clipPath: "inset(0% 0% round 0px)", ease: "none", duration: 1 },
0,
);
timeline.fromTo(
".reveal-photo",
{ scale: 1 + 0.12 * strength },
{ scale: 1, ease: "none", duration: 1 },
0,
);
timeline.to(
".reveal-caption",
{ y: -70 * strength, autoAlpha: 0, ease: "none", duration: 0.55 },
0,
);
}, 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">
Perspective / an unfolding study
</p>
<h1 className="mt-6 max-w-4xl font-serif text-6xl tracking-tight md:text-9xl">
There is more beyond the edge.
</h1>
<p className="mt-8 max-w-md text-lg text-muted-foreground">
A small opening. A broader possibility. Scroll to let the frame expand.
</p>
</header>
<section
className={`reveal-stage relative ${reduced ? "px-6 py-10" : "h-[100svh]"}`}
aria-label="An expanding photographic perspective"
>
<figure className={reduced ? "mx-auto max-w-6xl" : "relative h-full"}>
<div
className={
reduced
? "overflow-hidden bg-muted"
: "reveal-window absolute inset-0 overflow-hidden bg-muted"
}
>
<img
src="https://picsum.photos/seed/ardent-horizon/1920/1200"
alt="A panoramic photographic study inviting a wider perspective"
className={
reduced
? "aspect-[8/5] w-full object-cover"
: "reveal-photo h-full w-full object-cover"
}
width={1920}
height={1200}
onLoad={() => {
if (!reduced) ScrollTrigger.refresh();
}}
/>
</div>
<figcaption
className={
reduced
? "pt-8"
: "reveal-caption absolute inset-x-6 bottom-[12svh] mx-auto max-w-3xl border border-border bg-background p-6 md:p-10"
}
>
<p className="font-mono text-xs uppercase tracking-[0.2em] text-muted-foreground">
Beyond the familiar
</p>
<h2 className="mt-4 font-serif text-4xl tracking-tight md:text-7xl">Open the frame.</h2>
<p className="mt-4 max-w-md text-muted-foreground">
A change in perspective changes everything.
</p>
</figcaption>
</figure>
</section>
<footer className="px-6 py-[25vh] text-center font-serif text-4xl tracking-tight md:text-6xl">
Make room for possibility.
</footer>
</main>
);
}
Next patternPinned Scrollytelling→