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