Innovations

Scroll-triggered Reveal

Image grows, settles, and clip-path uncovers as it scrolls into view. A single coordinated entrance — not a loop.

Preview

Source

tsx
"use client";

import { useEffect, useRef } from "react";

export interface ParallaxScrollRevealProps {
  imageSrc?: string;
  eyebrow?: string;
  headline?: string;
  subhead?: string;
  /** How much the image starts scaled down (1.0 = no scale, 0.7 = grows from 70%). */
  startScale?: number;
  /** How far below its final position the image starts, in px. */
  startOffset?: number;
}

export default function ParallaxScrollReveal({
  imageSrc = "/heroes/cmillworks/walnut.webp",
  eyebrow = "Parallax · Scroll Reveal",
  headline = "An image that opens as you arrive",
  subhead =
    "Off-screen the photo is hidden behind a curtain. As you scroll it in, the clip-path lifts, the image scales up, and it settles into place.",
  startScale = 0.86,
  startOffset = 60,
}: ParallaxScrollRevealProps) {
  const sectionRef = useRef<HTMLElement | null>(null);
  const imgWrapRef = useRef<HTMLDivElement | null>(null);

  useEffect(() => {
    let raf = 0;
    let visible = false;
    const io = new IntersectionObserver(
      (entries) => {
        visible = entries[0]?.isIntersecting ?? false;
      },
      { threshold: 0 }
    );
    if (sectionRef.current) io.observe(sectionRef.current);

    const onScroll = () => {
      if (!visible || !sectionRef.current || !imgWrapRef.current) return;
      cancelAnimationFrame(raf);
      raf = requestAnimationFrame(() => {
        const rect = sectionRef.current!.getBoundingClientRect();
        const winH = window.innerHeight;
        // Progress: 0 when image first peeks above the fold, 1 when fully past
        const start = winH;
        const end = winH * 0.3;
        const p = Math.max(0, Math.min(1, (start - rect.top) / (start - end)));

        const scale = startScale + (1 - startScale) * p;
        const ty = startOffset * (1 - p);
        // Clip-path inset opens from all sides
        const inset = (1 - p) * 12; // 12% inset at start → 0 at full reveal
        imgWrapRef.current!.style.transform = `translate3d(0, ${ty}px, 0) scale(${scale})`;
        imgWrapRef.current!.style.clipPath = `inset(${inset}% round 24px)`;
      });
    };

    onScroll();
    window.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      io.disconnect();
      window.removeEventListener("scroll", onScroll);
      cancelAnimationFrame(raf);
    };
  }, [startScale, startOffset]);

  return (
    <section
      ref={sectionRef}
      className="relative w-full bg-stone-50 py-24 sm:py-32"
    >
      <div className="mx-auto max-w-3xl px-6 text-center">
        <p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-stone-500">
          {eyebrow}
        </p>
        <h2 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight text-stone-900 sm:text-5xl">
          {headline}
        </h2>
        <p className="mx-auto mt-5 max-w-xl text-base leading-relaxed text-stone-600 sm:text-lg">
          {subhead}
        </p>
      </div>

      <div className="mx-auto mt-16 max-w-5xl px-6 sm:mt-20">
        <div
          ref={imgWrapRef}
          className="aspect-[16/10] w-full overflow-hidden bg-stone-200 will-change-transform"
          style={{
            transform: `translate3d(0, ${startOffset}px, 0) scale(${startScale})`,
            clipPath: "inset(12% round 24px)",
          }}
        >
          <img
            src={imageSrc}
            alt=""
            className="h-full w-full object-cover"
            loading="lazy"
            decoding="async"
          />
        </div>
      </div>
    </section>
  );
}
Claude Code Instructions

CLI Install

npx innovations add scroll-reveal

Where to use it

Three coordinated transforms tied to scroll progress (0..1 as the image moves from below the fold to mostly past): - scale: startScale → 1.0 - translateY: startOffset → 0 - clip-path inset: 12% → 0% The result is an image that 'opens' as you arrive at it, then sits still. WHEN TO USE: - Editorial articles where a feature image deserves an entrance - Quote/pull-quote moments where the photo IS the punctuation - Single hero photos on portfolio pages (per-project galleries) - Avoid stacking many of these on one page — the effect loses meaning when it's everywhere TUNING: - startScale (default 0.86) — 1.0 disables the scale, 0.7 is dramatic - startOffset (default 60px) — vertical rise distance - Tweak the clip-path inset to taste, or swap inset() for circle() / polygon() for a different reveal shape PERFORMANCE: combines 2 GPU transforms + clip-path. clip-path is GPU-accelerated in modern browsers but can stutter on older mobile — test on real hardware if you need this on a low-end device.