parallax /
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-revealWhere 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.