Skip to content
ARDENT / MOTION LAB
Library

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→