Innovations

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-reveal

Where 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.