parallax /
Clip-path Aperture Reveal
Circular aperture grows from the center as the user scrolls. The image is constant — the mask animates.
Preview
Source
tsx
"use client";
import { useEffect, useRef } from "react";
export interface ParallaxClipRevealProps {
imageSrc?: string;
eyebrow?: string;
headline?: string;
subhead?: string;
}
export default function ParallaxClipReveal({
imageSrc = "/heroes/landscape-card-bg.webp",
eyebrow = "Parallax · 06",
headline = "Clip-path reveal",
subhead =
"A circular aperture grows from the center as you scroll. The image is always there — the mask is what changes. Pairs beautifully with bold headlines.",
}: ParallaxClipRevealProps) {
const sectionRef = useRef<HTMLElement | null>(null);
const imgRef = 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 || !imgRef.current) return;
cancelAnimationFrame(raf);
raf = requestAnimationFrame(() => {
const rect = sectionRef.current!.getBoundingClientRect();
const winH = window.innerHeight;
const total = rect.height + winH;
const p = Math.max(0, Math.min(1, (winH - rect.top) / total));
// Aperture grows from 6% to 150% of viewport diagonal
const radius = 6 + p * 144;
imgRef.current!.style.clipPath = `circle(${radius}% at 50% 50%)`;
});
};
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
return () => {
io.disconnect();
window.removeEventListener("scroll", onScroll);
cancelAnimationFrame(raf);
};
}, []);
return (
<section
ref={sectionRef}
className="relative isolate flex min-h-[180svh] w-full items-center justify-center overflow-hidden bg-slate-950"
>
{/* Bottom layer: dark, always visible */}
<div className="absolute inset-0 -z-20 bg-slate-950" />
{/* Top layer: image, revealed through the circular clip */}
<div
ref={imgRef}
className="absolute inset-0 -z-10 will-change-[clip-path]"
style={{
backgroundImage: `url(${imageSrc})`,
backgroundSize: "cover",
backgroundPosition: "center",
clipPath: "circle(6% at 50% 50%)",
}}
/>
<div className="absolute inset-0 -z-0 bg-gradient-to-b from-transparent via-transparent to-black/60" />
<div className="sticky top-1/2 z-10 mx-auto max-w-3xl -translate-y-1/2 px-6 text-center text-white">
<p className="mb-4 text-xs font-semibold uppercase tracking-[0.3em] text-white/70 mix-blend-difference">
{eyebrow}
</p>
<h1 className="text-balance text-4xl font-semibold leading-[1.1] tracking-tight text-white mix-blend-difference sm:text-6xl">
{headline}
</h1>
<p className="mx-auto mt-6 max-w-xl text-base leading-relaxed text-white/85 mix-blend-difference sm:text-lg">
{subhead}
</p>
</div>
</section>
);
} Claude Code Instructions
CLI Install
npx innovations add clip-revealWhere to use it
A circular clip-path expands from a small dot to fully covering the image. mix-blend-difference on the headline keeps it readable regardless of whether the aperture is dark (covered) or revealing the image.
Section is 180svh tall so the reveal has scroll runway to play out. The headline is position: sticky so it stays centered during the reveal.
VARIATIONS (edit clip-path):
- circle() — default, soft aperture
- inset() — rectangular curtain pull
- polygon() — angular wipe (diagonal, chevron, fractal)
- ellipse(60% 30% at 50% 100%) — bottom-up oval reveal
WHEN TO USE:
- 'Big reveal' product launches
- Storytelling moments where the image IS the punchline
- Loading screens before content
WARNING: clip-path is GPU-accelerated in modern browsers but can stutter on older mobile GPUs. Test on real hardware.