Iris
import { useEffect, useRef } from 'react';
const css = `
.btn-iris {
position: relative;
display: grid;
place-items: center;
width: 128px;
height: 128px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
cursor: pointer;
box-shadow: 0 0 0 1px light-dark(#cbced3, #393c41), 0 0 0 6px light-dark(#f3f4f7, #1a1c1f), 0 0 0 7px light-dark(#dbdde1, #2e3035);
}
.btn-iris:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 10px; }
.btn-iris__inside {
position: absolute;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
text-shadow: 0 0 12px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent);
opacity: 0;
transform: scale(0.7);
transition: opacity 0.3s ease, transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.btn-iris:hover .btn-iris__inside,
.btn-iris:focus-visible .btn-iris__inside { opacity: 1; transform: none; transition-delay: 0.1s; }
.btn-iris svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.btn-iris__blade { stroke: light-dark(#fbfcfd, #121417); stroke-width: 1; stroke-linejoin: round; }
.btn-iris__blade:nth-child(odd) { fill: light-dark(#ebedf0, #232529); }
.btn-iris__blade:nth-child(even) { fill: light-dark(#dbdde1, #2e3035); }
`;
const R = 64; // aperture radius (viewBox units)
const N = 6;
const CLOSED = 1;
const OPEN = 50;
type P = { x: number; y: number };
/** Where the ray from `p` along unit `d` leaves the aperture circle. */
function exit(p: P, d: P): P {
const b = p.x * d.x + p.y * d.y;
const t = -b + Math.sqrt(b * b - (p.x * p.x + p.y * p.y - R * R));
return { x: p.x + d.x * t, y: p.y + d.y * t };
}
/**
* The six blades for an opening of radius r. The opening is a hexagon that twists
* as it grows; each blade is bounded by one hexagon edge carried on to the rim and
* the previous edge's extension, so the blades always tile the disc exactly.
*/
function blades(r: number): string[] {
const twist = (1 - r / OPEN) * 0.9;
const v = Array.from({ length: N }, (_, i) => {
const a = (i / N) * Math.PI * 2 + twist;
return { x: Math.cos(a) * r, y: Math.sin(a) * r };
});
const ends = v.map((p, i) => {
const q = v[(i + 1) % N];
const len = Math.hypot(q.x - p.x, q.y - p.y);
return exit(q, { x: (q.x - p.x) / len, y: (q.y - p.y) / len });
});
return v.map((p, i) => {
const q = v[(i + 1) % N];
const e = ends[i];
const prev = ends[(i + N - 1) % N];
let a0 = Math.atan2(e.y, e.x);
let a1 = Math.atan2(prev.y, prev.x);
while (a1 > a0) a1 -= Math.PI * 2; // walk the rim backwards from e to prev
let d = `M${p.x.toFixed(2)} ${p.y.toFixed(2)}L${q.x.toFixed(2)} ${q.y.toFixed(2)}L${e.x.toFixed(2)} ${e.y.toFixed(2)}`;
const steps = 10;
for (let s = 1; s <= steps; s++) {
const a = a0 + ((a1 - a0) * s) / steps;
d += `L${(Math.cos(a) * R).toFixed(2)} ${(Math.sin(a) * R).toFixed(2)}`;
}
return `${d}Z`;
});
}
export function IrisButton() {
const group = useRef<SVGGElement>(null);
const target = useRef(CLOSED);
useEffect(() => {
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let r = CLOSED;
let raf = 0;
const draw = () => {
const paths = group.current!.children;
blades(r).forEach((d, i) => paths[i].setAttribute('d', d));
};
const tick = () => {
r = still ? target.current : r + (target.current - r) * 0.14;
draw();
raf = requestAnimationFrame(tick);
};
draw();
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
const open = () => (target.current = OPEN);
const close = () => (target.current = CLOSED);
return (
<>
<style>
{css}
</style>
<button
type="button"
className="btn-iris"
onPointerEnter={open}
onPointerLeave={close}
onFocus={open}
onBlur={close}
>
<span className="btn-iris__inside">Open</span>
<svg viewBox={`${-R} ${-R} ${R * 2} ${R * 2}`} aria-hidden="true">
<clipPath id="btn-iris-aperture">
<circle r={R} />
</clipPath>
<g ref={group} clipPath="url(#btn-iris-aperture)">
{Array.from({ length: N }, (_, i) => (
<path key={i} className="btn-iris__blade" d={blades(CLOSED)[i]} />
))}
</g>
</svg>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnIris(el) on the mounted element instead.
<button type="button" class="btn-iris">
<span class="btn-iris__inside">Open</span>
<svg viewBox="-64 -64 128 128" aria-hidden="true">
<clipPath id="btn-iris-aperture">
<circle r="64"></circle>
</clipPath>
<g clip-path="url(#btn-iris-aperture)">
<path class="btn-iris__blade" d="M0.64 0.77L-0.35 0.94L-62.98 11.38L-61.45 17.90L-59.24 24.22L-56.38 30.28L-52.91 36.01L-48.85 41.34L-44.26 46.22L-39.19 50.60L-33.69 54.42L-27.81 57.64L-21.64 60.23Z"></path>
<path class="btn-iris__blade" d="M-0.35 0.94L-0.99 0.16L-41.34 -48.85L-46.22 -44.26L-50.60 -39.19L-54.42 -33.69L-57.64 -27.81L-60.23 -21.64L-62.16 -15.22L-63.41 -8.64L-63.97 -1.97L-63.82 4.73L-62.98 11.38Z"></path>
<path class="btn-iris__blade" d="M-0.99 0.16L-0.64 -0.77L21.64 -60.23L15.22 -62.16L8.64 -63.41L1.97 -63.97L-4.73 -63.82L-11.38 -62.98L-17.90 -61.45L-24.22 -59.24L-30.28 -56.38L-36.01 -52.91L-41.34 -48.85Z"></path>
<path class="btn-iris__blade" d="M-0.64 -0.77L0.35 -0.94L62.98 -11.38L61.45 -17.90L59.24 -24.22L56.38 -30.28L52.91 -36.01L48.85 -41.34L44.26 -46.22L39.19 -50.60L33.69 -54.42L27.81 -57.64L21.64 -60.23Z"></path>
<path class="btn-iris__blade" d="M0.35 -0.94L0.99 -0.16L41.34 48.85L46.22 44.26L50.60 39.19L54.42 33.69L57.64 27.81L60.23 21.64L62.16 15.22L63.41 8.64L63.97 1.97L63.82 -4.73L62.98 -11.38Z"></path>
<path class="btn-iris__blade" d="M0.99 -0.16L0.64 0.77L-21.64 60.23L-15.22 62.16L-8.64 63.41L-1.97 63.97L4.73 63.82L11.38 62.98L17.90 61.45L24.22 59.24L30.28 56.38L36.01 52.91L41.34 48.85Z"></path>
</g>
</svg>
</button>.btn-iris {
position: relative;
display: grid;
place-items: center;
width: 128px;
height: 128px;
padding: 0;
border: none;
border-radius: 50%;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
cursor: pointer;
box-shadow: 0 0 0 1px light-dark(#cbced3, #393c41), 0 0 0 6px light-dark(#f3f4f7, #1a1c1f), 0 0 0 7px light-dark(#dbdde1, #2e3035);
}
.btn-iris:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 10px; }
.btn-iris__inside {
position: absolute;
display: grid;
place-items: center;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
text-shadow: 0 0 12px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 50%, transparent);
opacity: 0;
transform: scale(0.7);
transition: opacity 0.3s ease, transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}
.btn-iris:hover .btn-iris__inside,
.btn-iris:focus-visible .btn-iris__inside { opacity: 1; transform: none; transition-delay: 0.1s; }
.btn-iris svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.btn-iris__blade { stroke: light-dark(#fbfcfd, #121417); stroke-width: 1; stroke-linejoin: round; }
.btn-iris__blade:nth-child(odd) { fill: light-dark(#ebedf0, #232529); }
.btn-iris__blade:nth-child(even) { fill: light-dark(#dbdde1, #2e3035); }function initBtnIris(root) {
var group = root.querySelector('svg g');
if (!group) return;
var R = 64; // aperture radius (viewBox units)
var N = 6;
var CLOSED = 1;
var OPEN = 50;
var paths = group.children;
// Where the ray from p along unit d leaves the aperture circle.
function exit(p, d) {
var b = p.x * d.x + p.y * d.y;
var t = -b + Math.sqrt(b * b - (p.x * p.x + p.y * p.y - R * R));
return { x: p.x + d.x * t, y: p.y + d.y * t };
}
// Six blades for an opening of radius r: a twisting hexagon whose edges run on to the rim, so they always tile the disc.
function blades(r) {
var twist = (1 - r / OPEN) * 0.9;
var v = [];
for (var i = 0; i < N; i++) {
var a = (i / N) * Math.PI * 2 + twist;
v.push({ x: Math.cos(a) * r, y: Math.sin(a) * r });
}
var ends = v.map(function (p, i) {
var q = v[(i + 1) % N];
var len = Math.hypot(q.x - p.x, q.y - p.y);
return exit(q, { x: (q.x - p.x) / len, y: (q.y - p.y) / len });
});
return v.map(function (p, i) {
var q = v[(i + 1) % N];
var e = ends[i];
var prev = ends[(i + N - 1) % N];
var a0 = Math.atan2(e.y, e.x);
var a1 = Math.atan2(prev.y, prev.x);
while (a1 > a0) a1 -= Math.PI * 2; // walk the rim backwards from e to prev
var d = 'M' + p.x.toFixed(2) + ' ' + p.y.toFixed(2) + 'L' + q.x.toFixed(2) + ' ' + q.y.toFixed(2) + 'L' + e.x.toFixed(2) + ' ' + e.y.toFixed(2);
for (var s = 1; s <= 10; s++) {
var ang = a0 + ((a1 - a0) * s) / 10;
d += 'L' + (Math.cos(ang) * R).toFixed(2) + ' ' + (Math.sin(ang) * R).toFixed(2);
}
return d + 'Z';
});
}
var still = matchMedia('(prefers-reduced-motion: reduce)').matches;
var target = CLOSED;
var r = CLOSED;
var raf = 0;
function tick() {
r = still ? target : r + (target - r) * 0.14;
blades(r).forEach(function (d, i) { paths[i].setAttribute('d', d); });
raf = requestAnimationFrame(tick);
}
function open() { target = OPEN; }
function close() { target = CLOSED; }
root.addEventListener('pointerenter', open);
root.addEventListener('pointerleave', close);
root.addEventListener('focus', open);
root.addEventListener('blur', close);
raf = requestAnimationFrame(tick);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointerenter', open);
root.removeEventListener('pointerleave', close);
root.removeEventListener('focus', open);
root.removeEventListener('blur', close);
};
}
document.querySelectorAll('.btn-iris').forEach(initBtnIris);Goal: Create a "Iris" button.
Visual behavior: A camera aperture as a button. Six shaded blades meet in a closed iris; on hover or focus they twist open together to reveal the action glowing inside, and close again when you leave.
Technique:
- The blades are six SVG paths inside a circular clipPath. For an opening of radius r the hole is a hexagon that twists as it shrinks.
- Each blade is bounded by one hexagon edge carried on to the rim (ray-circle intersection), the previous edge's extension, and the arc of rim between them, so the six always tile the disc exactly.
- A rAF loop eases r toward 50 (open) or 1 (closed) and rebuilds the six paths every frame; alternate blades take two shades.
- The label inside scales and fades in with a short delay as the aperture opens.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus opens it like hover. Under prefers-reduced-motion the aperture snaps open and shut.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Iris" button.
Visual behavior: A camera aperture as a button. Six shaded blades meet in a closed iris; on hover or focus they twist open together to reveal the action glowing inside, and close again when you leave.
Technique:
- The blades are six SVG paths inside a circular clipPath. For an opening of radius r the hole is a hexagon that twists as it shrinks.
- Each blade is bounded by one hexagon edge carried on to the rim (ray-circle intersection), the previous edge's extension, and the arc of rim between them, so the six always tile the disc exactly.
- A rAF loop eases r toward 50 (open) or 1 (closed) and rebuilds the six paths every frame; alternate blades take two shades.
- The label inside scales and fades in with a short delay as the aperture opens.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. Focus opens it like hover. Under prefers-reduced-motion the aperture snaps open and shut.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
About this component
A camera aperture as a button. Six shaded blades meet in a closed iris; on hover or focus they twist open together to reveal the action glowing inside, and close again when you leave.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, focus, morph, state change
- Techniques
- svg, raf, clip path
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. Focus opens it like hover. Under prefers-reduced-motion the aperture snaps open and shut.