"use client"; import { useId, useRef, useState } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform } from "motion/react"; export type ShutterIrisImage = { src: string; alt: string }; type ShutterIrisProps = { images: ShutterIrisImage[]; /** How open the iris sits at rest, 0 to 1. */ rest?: number; /** How open it goes on hover and focus, 0 to 1. */ wide?: number; /** Number of metal blades, 5 to 12. */ blades?: number; /** Called with the new picture's index after each shot. */ onShoot?: (index: number) => void; className?: string; }; const SIZE = 400; const MID = SIZE / 2; const FAR = 460; const STOPS = ["1.4", "2", "2.8", "4", "5.6", "8", "11", "16"]; const GAP = 42; const SPRING = { type: "spring", stiffness: 170, damping: 19 } as const; function point(a: number, d: number, x = MID, y = MID): [number, number] { return [x + Math.cos(a) * d, y + Math.sin(a) * d]; } const line = (p: [number, number]) => `${p[0].toFixed(1)} ${p[1].toFixed(1)}`; /* Each blade is one big rectangle whose inner side is an edge of the opening, so the rectangles together leave a true polygon that twists as it closes. */ function iris(open: number, n: number) { const r = Math.max(0, open) * 158; const twist = 1.25 * (1 - open); let metal = ""; let edges = ""; for (let i = 0; i < n; i++) { const a = twist + (i * Math.PI * 2) / n; const out = a + Math.PI / n; const along = out + Math.PI / 2; const p = point(a, r); const q = point(along, FAR, p[0], p[1]); metal += `M${line(p)}L${line(q)}L${line(point(out, FAR, q[0], q[1]))}L${line(point(out, FAR, p[0], p[1]))}Z`; edges += `M${line(p)}L${line(q)}`; } return { metal, edges }; } /* ShutterIris: a camera lens seen from the front, the picture small behind the iris. Hover opens the blades, press fires the shutter and loads the next picture. */ export function ShutterIris({ images, rest = 0.3, wide = 0.97, blades = 7, onShoot, className = "" }: ShutterIrisProps) { const reduce = useReducedMotion(); const id = useId(); const n = Math.min(12, Math.max(5, blades)); const [shot, setShot] = useState(0); const near = useRef(false); const firing = useRef(false); const open = useMotionValue(rest); const metal = useTransform(open, (v) => iris(v, n).metal); const edges = useTransform(open, (v) => iris(v, n).edges); const turn = useTransform(open, (v) => -GAP * Math.min(STOPS.length - 1, Math.max(0, 2 * Math.log2(0.97 / Math.max(0.01, v))))); function settle() { const goal = near.current ? wide : rest; if (reduce) open.set(goal); else animate(open, goal, SPRING); } function hover(on: boolean) { near.current = on; if (!firing.current) settle(); } function next() { const to = (shot + 1) % images.length; setShot(to); onShoot?.(to); } async function fire() { if (firing.current) return; if (reduce) return next(); firing.current = true; await animate(open, 0, { duration: 0.1, ease: "easeIn" }); next(); await new Promise((done) => setTimeout(done, 70)); firing.current = false; settle(); } return (
); }