"use client";
import { useEffect, useId, useRef, type ReactNode } from "react";
import { animate, motion, useMotionValue, useTransform } from "motion/react";
import { cn } from "@/lib/utils";
export interface ApertureProps {
children?: ReactNode;
bladeCount?: number;
openness?: number;
duration?: number;
detent?: number;
bladeCurvature?: number;
edgeHighlight?: number;
housingInset?: number;
autoOpen?: boolean;
bladeColor?: string;
edgeColor?: string;
reducedMotion?: boolean;
className?: string;
}
const BASE_RHO = 160;
const OPEN_MIN = -5;
const OPEN_MAX = 64;
const LIGHT = -55;
const FLOOR_OUT = 300;
const phiFor = (openness: number) =>
Math.PI * (1 - Math.min(1, Math.max(0, openness)));
const dist = (p: number, pivot: number, arm: number) =>
Math.sqrt(pivot * pivot + arm * arm + 2 * pivot * arm * Math.cos(p));
export default function Aperture({
children,
bladeCount = 8,
openness = 0.62,
duration = 900,
detent = 0.35,
bladeCurvature = 1,
edgeHighlight = 0.6,
housingInset = 6,
autoOpen = true,
bladeColor = "#26232c",
edgeColor = "#000000",
reducedMotion = false,
className,
}: ApertureProps) {
const phi = useMotionValue(phiFor(openness));
const rho = BASE_RHO * bladeCurvature;
const arm = (OPEN_MAX - OPEN_MIN) / 2;
const pivot = rho + (OPEN_MAX + OPEN_MIN) / 2;
const count = Math.max(3, Math.round(bladeCount));
const blades = Array.from({ length: count }, (_, i) => i);
const angle = (i: number) => (i * 360) / count;
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, "");
const cx = useTransform(phi, (p) => pivot + arm * Math.cos(p));
const cy = useTransform(phi, (p) => arm * Math.sin(p));
const live = useRef({ duration, detent, openness });
live.current = { duration, detent, openness };
useEffect(() => {
const ease = () =>
[0.34, 1 + live.current.detent, 0.64, 1] as [
number,
number,
number,
number,
];
if (reducedMotion || !autoOpen) {
animate(
phi,
phiFor(live.current.openness),
reducedMotion
? { duration: 0 }
: { duration: live.current.duration / 1000, ease: ease() },
);
return;
}
let alive = true;
let timer: ReturnType<typeof setTimeout> | null = null;
const leg = (open: boolean) => {
void animate(phi, phiFor(open ? 0.95 : 0.08), {
duration: live.current.duration / 1000,
ease: ease(),
}).then(() => {
if (!alive) return;
timer = setTimeout(() => {
if (alive) leg(!open);
}, 720);
});
};
leg(true);
return () => {
alive = false;
if (timer !== null) clearTimeout(timer);
};
}, [autoOpen, reducedMotion, openness, duration, phi]);
const vertexR = (p: number) => {
const d = dist(p, pivot, arm);
const s = Math.sin(Math.PI / count);
const under = rho * rho - d * d * s * s;
const r =
under > 0 ? d * Math.cos(Math.PI / count) - Math.sqrt(under) : d - rho;
return Math.max(0, r);
};
const floorR = useTransform(phi, (p) => (vertexR(p) + FLOOR_OUT) / 2);
const floorW = useTransform(phi, (p) => FLOOR_OUT - vertexR(p));
const lightRad = (LIGHT * Math.PI) / 180;
const lx = Math.cos(lightRad) * 100;
const ly = Math.sin(lightRad) * 100;
const litAt = (i: number) =>
0.5 + 0.5 * Math.cos(((angle(i) - LIGHT) * Math.PI) / 180);
return (
<div
className={cn(
"relative aspect-square overflow-hidden rounded-full",
className,
)}
style={{ backgroundColor: bladeColor }}
>
<div className="absolute inset-0">{children}</div>
<svg
viewBox="-100 -100 200 200"
className="absolute inset-0 h-full w-full"
aria-hidden
>
<defs>
<clipPath id={`${uid}-housing`} clipPathUnits="userSpaceOnUse">
<circle cx="0" cy="0" r="100" />
</clipPath>
<linearGradient
id={`${uid}-sheen`}
gradientUnits="userSpaceOnUse"
x1={lx}
y1={ly}
x2={-lx}
y2={-ly}
>
<stop offset="0" stopColor="#ffffff" stopOpacity="0.08" />
<stop offset="0.5" stopColor="#ffffff" stopOpacity="0" />
<stop offset="1" stopColor="#000000" stopOpacity="0.24" />
</linearGradient>
{blades.map((i) => (
<mask
key={i}
id={`${uid}-cut-${i}`}
maskUnits="userSpaceOnUse"
x="-100"
y="-100"
width="200"
height="200"
>
<rect
x="-100"
y="-100"
width="200"
height="200"
fill="#ffffff"
/>
<g transform={`rotate(${angle((i + 1) % count)})`}>
<motion.circle cx={cx} cy={cy} r={rho} fill="#000000" />
</g>
</mask>
))}
</defs>
<g clipPath={`url(#${uid}-housing)`}>
<motion.circle
cx="0"
cy="0"
r={floorR}
fill="none"
stroke={bladeColor}
strokeWidth={floorW}
/>
{blades.map((i) => (
<g key={i} transform={`rotate(${angle(i)})`}>
<motion.circle cx={cx} cy={cy} r={rho} fill={bladeColor} />
</g>
))}
<rect
x="-100"
y="-100"
width="200"
height="200"
fill={`url(#${uid}-sheen)`}
/>
{blades.map((i) => (
<g key={i} mask={`url(#${uid}-cut-${i})`}>
<g transform={`rotate(${angle(i)})`}>
<motion.circle
cx={cx}
cy={cy}
r={rho}
fill="none"
stroke={edgeColor}
strokeOpacity={edgeHighlight * (0.62 + 0.38 * litAt(i))}
strokeWidth="1"
/>
</g>
</g>
))}
</g>
<circle
cx="0"
cy="0"
r={100 - housingInset / 2}
fill="none"
stroke={bladeColor}
strokeWidth={housingInset}
/>
<circle
cx="0"
cy="0"
r={100 - housingInset}
fill="none"
stroke={edgeColor}
strokeOpacity={edgeHighlight * 0.5}
strokeWidth="0.7"
/>
</svg>
</div>
);
}