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.
npx shadcn@latest add @paragon/clip-path-reveal"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>
);
}