Image Effects
ShutterIris
A camera lens seen from the front, the picture small behind a real many-sided iris. Hover or focus twists the blades open and turns the f-stop ring. Press fires the shutter and the next picture is behind it.
Download
- React
- Tailwind CSS v4
- motion
components/ui/shutter-iris.tsx
"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 (
<div className={`@container w-80 ${className}`}>
<button
type="button"
aria-label={`${images[shot].alt}. Fire the shutter for the next picture`}
onPointerEnter={(e) => e.pointerType === "mouse" && hover(true)}
onPointerLeave={() => hover(false)}
onFocus={(e) => e.currentTarget.matches(":focus-visible") && hover(true)}
onBlur={() => hover(false)}
onClick={fire}
style={{ borderRadius: "50%" }}
className="relative block aspect-square w-full rounded-full outline-none focus-visible:ring-[1cqw] focus-visible:ring-[#e8c15a] focus-visible:ring-offset-[1cqw] focus-visible:ring-offset-black"
>
<span className="absolute inset-[10%] overflow-hidden rounded-full bg-black">
{images.map((image, i) => (
<img key={image.src} src={image.src} alt="" draggable={false} className={`absolute inset-0 size-full object-cover ${i === shot ? "" : "opacity-0"}`} />
))}
</span>
<svg aria-hidden viewBox={`0 0 ${SIZE} ${SIZE}`} className="absolute inset-0 size-full">
<defs>
<clipPath id={`${id}c`}>
<circle cx={MID} cy={MID} r="161" />
</clipPath>
<radialGradient id={`${id}m`} gradientUnits="userSpaceOnUse" cx={MID} cy={MID} r="170">
<stop offset="0" stopColor="#3b3d44" />
<stop offset="0.55" stopColor="#23252b" />
<stop offset="1" stopColor="#0d0e11" />
</radialGradient>
<linearGradient id={`${id}b`} x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stopColor="#40424a" />
<stop offset="0.5" stopColor="#141519" />
<stop offset="1" stopColor="#2c2e34" />
</linearGradient>
</defs>
<circle cx={MID} cy={MID} r="179" fill="none" stroke="#0a0a0c" strokeWidth="37" />
<circle cx={MID} cy={MID} r="192" fill="none" stroke="#2d2f35" strokeWidth="11" strokeDasharray="3 3.2" />
<circle cx={MID} cy={MID} r="166" fill="none" stroke={`url(#${id}b)`} strokeWidth="11" />
<g clipPath={`url(#${id}c)`}>
<motion.path d={metal} fill={`url(#${id}m)`} />
<motion.path d={edges} fill="none" stroke="#000" strokeOpacity="0.5" strokeWidth="11" />
<motion.path d={edges} fill="none" stroke="#83868f" strokeOpacity="0.75" strokeWidth="1.2" />
</g>
<circle cx={MID} cy={MID} r="161" fill="none" stroke="#000" strokeWidth="5" />
</svg>
<motion.svg aria-hidden viewBox={`0 0 ${SIZE} ${SIZE}`} style={{ rotate: turn }} className="absolute inset-0 size-full">
{STOPS.map((stop, i) => (
<text key={stop} x={MID} y={MID - 174} transform={`rotate(${i * GAP} ${MID} ${MID})`} fill={i < 2 ? "#e8c15a" : "#e7e3d6"} fontSize="13" fontFamily="ui-monospace, monospace" textAnchor="middle">{stop}</text>
))}
</motion.svg>
<svg aria-hidden viewBox={`0 0 ${SIZE} ${SIZE}`} className="pointer-events-none absolute inset-0 size-full">
<path d={`M${MID - 6} 3L${MID + 6} 3L${MID} 13Z`} fill="#e8c15a" />
<path id={`${id}t`} d={`M${MID - 164} ${MID}A164 164 0 0 0 ${MID + 164} ${MID}`} fill="none" />
<text fill="#8f8f96" fontSize="7.5" fontFamily="ui-monospace, monospace" letterSpacing="2.5" textAnchor="middle">
<textPath href={`#${id}t`} startOffset="50%">50 mm 1:1.4</textPath>
</text>
</svg>
<span className="pointer-events-none absolute inset-[10%] rounded-full bg-[radial-gradient(ellipse_at_32%_24%,rgba(190,215,255,0.34),transparent_30%),radial-gradient(ellipse_at_72%_78%,rgba(255,170,120,0.16),transparent_26%)]" />
</button>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/shutter-iris.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/shutter-iris-demo.tsx
import { ShutterIris } from "@/components/ui/shutter-iris"; const IMAGES = [ { src: "/demo/ember.jpg", alt: "Red hills under a burning sky" }, { src: "/demo/dawn.jpg", alt: "Teal hills at dawn" }, { src: "/demo/moss.jpg", alt: "Green hills in soft light" }, { src: "/demo/tide.jpg", alt: "Blue hills under a cold moon" }, ]; export default function ShutterIrisExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#141416] p-2"> <ShutterIris images={IMAGES} className="w-[min(92cqh,88cqw,340px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| images | ShutterIrisImage[] | — | One or more pictures, each with src and alt. |
| rest | number | 0.3 | How open the iris sits at rest, 0 to 1. |
| wide | number | 0.97 | How open it goes on hover and focus, 0 to 1. |
| blades | number | 7 | Number of metal blades, 5 to 12. |
| onShoot | (index: number) => void | — | Called with the new picture's index after each shot. |
| className | string | "w-80" | Set the width here. The lens is always round. |