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],
};

/** Build the {hidden, shown} clip-path pair for a shape + origin. */
function clipPair(
  shape: ClipShape,
  origin: ClipOrigin,
): { hidden: string; shown: string } {
  const [ox, oy] = ORIGIN_XY[origin];
  switch (shape) {
    case "circle":
    case "iris":
      return {
        hidden: `circle(0% at ${ox}% ${oy}%)`,
        shown: `circle(150% at ${ox}% ${oy}%)`,
      };
    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 diagonal wipe: same 4-vertex polygon, closed → open.
      return {
        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 (8 top/bottom teeth).
      return {
        hidden:
          "polygon(0% 0%, 0% 0%, 0% 12%, 0% 0%, 0% 12%, 0% 0%, 0% 12%, 0% 0%, 0% 100%, 0% 88%, 0% 100%, 0% 88%, 0% 100%, 0% 88%, 0% 100%)",
        shown:
          "polygon(0% 0%, 30% 0%, 45% 12%, 60% 0%, 75% 12%, 90% 0%, 105% 12%, 120% 0%, 120% 100%, 105% 88%, 90% 100%, 75% 88%, 60% 100%, 45% 88%, 30% 100%, 0% 100%)",
      };
    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 diagonal/zigzag). */
  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/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.
 *
 * Runs once on scroll-into-view (`useInView`, once) or on hover/click. Under
 * `prefers-reduced-motion` the content is shown fully clipped-open with no
 * animation. The polygon variants keep a fixed vertex count between the hidden
 * and shown states so the path interpolates smoothly instead of snapping.
 */
export function ClipPathReveal({
  shape = "circle",
  origin = "center",
  duration = 0.8,
  trigger = "view",
  className,
  children,
  ...props
}: ClipPathRevealProps) {
  const ref = React.useRef<HTMLDivElement>(null);
  const inView = useInView(ref, { once: true, amount: 0.35 });
  const reduce = useReducedMotion();
  const [hovered, setHovered] = React.useState(false);
  const [clicked, setClicked] = React.useState(false);

  const open =
    reduce ||
    (trigger === "view" && inView) ||
    (trigger === "hover" && hovered) ||
    (trigger === "click" && clicked);

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

  return (
    <div
      ref={ref}
      data-slot="clip-path-reveal"
      className={cn("relative overflow-hidden", className)}
      onMouseEnter={trigger === "hover" ? () => setHovered(true) : undefined}
      onClick={trigger === "click" ? () => setClicked(true) : undefined}
      {...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>
  );
}