Clip Path Reveal
Reveals & Transitions

Clip Path Reveal

Content is uncovered by an animated CSS clip-path shape growing from a chosen origin: expanding circle, diagonal wipe, saw-tooth zig-zag, or diamond.

Install

npx shadcn@latest add @paragon/clip-path-reveal

clip-path-reveal.tsx

"use client";

import * as React from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
import { cn } from "@/lib/utils";

export type ClipShape = "circle" | "diagonal" | "zigzag" | "diamond" | "iris";
export type ClipOrigin =
  | "center"
  | "top-left"
  | "top-right"
  | "bottom-left"
  | "bottom-right";

const ORIGIN_XY: Record<ClipOrigin, [number, number]> = {
  center: [50, 50],
  "top-left": [0, 0],
  "top-right": [100, 0],
  "bottom-left": [0, 100],
  "bottom-right": [100, 100],
};

const round2 = (n: number) => Math.round(n * 100) / 100;

/**
 * Build the {hidden, shown} clip-path pair for a shape + origin. Every polygon
 * pair keeps an identical vertex count so the path interpolates smoothly —
 * mismatched counts make clip-path snap instead of morph.
 */
function clipPair(
  shape: ClipShape,
  origin: ClipOrigin,
): { hidden: string; shown: string } {
  const [ox, oy] = ORIGIN_XY[origin];
  switch (shape) {
    case "circle":
      return {
        hidden: `circle(0% at ${ox}% ${oy}%)`,
        shown: `circle(150% at ${ox}% ${oy}%)`,
      };
    case "iris": {
      // A six-blade aperture: a hexagon that twists ~75° while it expands from
      // the origin, like a camera iris opening. Vertices are computed, and the
      // rotation comes free from linear vertex interpolation.
      const blades = 6;
      const poly = (r: number, rot: number) => {
        const pts: string[] = [];
        for (let i = 0; i < blades; i++) {
          const a = rot + (i / blades) * Math.PI * 2;
          pts.push(
            `${round2(ox + Math.cos(a) * r)}% ${round2(oy + Math.sin(a) * r)}%`,
          );
        }
        return `polygon(${pts.join(", ")})`;
      };
      // Hidden radius is exactly 0 (a fully-closed aperture) so no speck of
      // content shows on the first frame; the twist comes free from lerping
      // each vertex between the two rotations.
      return { hidden: poly(0, -Math.PI / 3), shown: poly(160, Math.PI / 12) };
    }
    case "diamond": {
      // A rhombus that grows from the origin outward.
      return {
        hidden: `polygon(${ox}% ${oy}%, ${ox}% ${oy}%, ${ox}% ${oy}%, ${ox}% ${oy}%)`,
        shown: `polygon(50% -60%, 160% 50%, 50% 160%, -60% 50%)`,
      };
    }
    case "diagonal": {
      // A slanted wipe. Right-side origins sweep right → left; everything else
      // sweeps left → right. Same 4 vertices, closed → open.
      const fromRight = origin === "top-right" || origin === "bottom-right";
      return fromRight
        ? {
            hidden:
              "polygon(100% 0%, 100% 0%, 140% 100%, 140% 100%)",
            shown: "polygon(100% 0%, -40% 0%, 0% 100%, 100% 100%)",
          }
        : {
            hidden: "polygon(0% 0%, 0% 0%, -40% 100%, -40% 100%)",
            shown: "polygon(0% 0%, 140% 0%, 100% 100%, 0% 100%)",
          };
    }
    case "zigzag": {
      // A saw-tooth leading edge sweeping left → right. The edge is generated
      // (6 tooth pairs) so hidden/shown vertex counts match exactly.
      const steps = 12;
      const depth = 7;
      const poly = (base: number) => {
        const pts: string[] = ["0% 0%"];
        for (let i = 0; i <= steps; i++) {
          const yPct = round2((i / steps) * 100);
          const xPct = round2(base + (i % 2 === 0 ? 0 : depth));
          pts.push(`${xPct}% ${yPct}%`);
        }
        pts.push("0% 100%");
        return `polygon(${pts.join(", ")})`;
      };
      return { hidden: poly(-depth), shown: poly(107) };
    }
    default:
      return { hidden: "inset(0 100% 0 0)", shown: "inset(0 0 0 0)" };
  }
}

export interface ClipPathRevealProps extends React.ComponentProps<"div"> {
  /** Clip shape that expands to uncover the content. */
  shape?: ClipShape;
  /** Origin the shape grows from (ignored by zigzag; diagonal uses left/right). */
  origin?: ClipOrigin;
  /** Reveal duration, in seconds. */
  duration?: number;
  /** How the reveal is triggered. */
  trigger?: "view" | "hover" | "click";
  children: React.ReactNode;
}

/**
 * ClipPathReveal — the content is uncovered by an animated CSS `clip-path`
 * shape that grows from a chosen origin: an expanding circle, a twisting
 * six-blade iris, a diagonal wipe, a saw-tooth zig-zag edge, or a diamond. The
 * clip is animated purely via `clip-path` (GPU-composited), so nothing reflows,
 * and the content starts clipped closed on the very first frame — no flash.
 *
 * Triggers: `view` runs once on scroll-into-view (`useInView`, once); `hover`
 * opens on hover and closes on leave (interruptible — the clip retargets
 * mid-flight), falling back to `view` on touch devices where hover never
 * fires; `click` is keyboard-operable (Enter/Space) until opened. Under
 * `prefers-reduced-motion` the content is shown fully clipped-open, no motion.
 */
export function ClipPathReveal({
  shape = "circle",
  origin = "center",
  duration = 0.8,
  trigger = "view",
  className,
  children,
  onClick,
  onKeyDown,
  onPointerEnter,
  onPointerLeave,
  ...props
}: ClipPathRevealProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.35 });
  const reduce = useReducedMotion();
  const [fine, setFine] = React.useState(false);
  const [hovered, setHovered] = React.useState(false);
  const [clicked, setClicked] = React.useState(false);

  React.useEffect(() => {
    if (typeof window === "undefined" || !window.matchMedia) return;
    const mql = window.matchMedia("(hover: hover) and (pointer: fine)");
    const sync = () => setFine(mql.matches);
    sync();
    mql.addEventListener("change", sync);
    return () => mql.removeEventListener("change", sync);
  }, []);

  // Hover can never fire on touch — fall back to the in-view reveal there.
  const effectiveTrigger = trigger === "hover" && !fine ? "view" : trigger;
  const open =
    reduce ||
    (effectiveTrigger === "view" && inView) ||
    (effectiveTrigger === "hover" && hovered) ||
    (effectiveTrigger === "click" && clicked);

  const { hidden, shown } = React.useMemo(
    () => clipPair(shape, origin),
    [shape, origin],
  );

  const awaitingClick = effectiveTrigger === "click" && !clicked && !reduce;

  return (
    <div
      ref={ref}
      data-slot="clip-path-reveal"
      className={cn("relative overflow-hidden", className)}
      role={awaitingClick ? "button" : undefined}
      tabIndex={awaitingClick ? 0 : undefined}
      aria-label={awaitingClick ? "Reveal content" : undefined}
      onPointerEnter={(e) => {
        onPointerEnter?.(e);
        if (effectiveTrigger === "hover") setHovered(true);
      }}
      onPointerLeave={(e) => {
        onPointerLeave?.(e);
        if (effectiveTrigger === "hover") setHovered(false);
      }}
      onClick={(e) => {
        onClick?.(e);
        if (effectiveTrigger === "click") setClicked(true);
      }}
      onKeyDown={(e) => {
        onKeyDown?.(e);
        if (awaitingClick && (e.key === "Enter" || e.key === " ")) {
          e.preventDefault();
          setClicked(true);
        }
      }}
      {...props}
    >
      <motion.div
        className="size-full"
        style={{ clipPath: reduce ? shown : undefined }}
        initial={{ clipPath: hidden }}
        animate={{ clipPath: open ? shown : hidden }}
        transition={{
          duration: reduce ? 0 : duration,
          ease: [0.22, 1, 0.36, 1],
        }}
      >
        {children}
      </motion.div>
    </div>
  );
}