Iris
import { useState } from 'react';
const STOPS = [1.4, 2, 2.8, 4, 5.6, 8, 11, 16];
const BOKEH = [{ x: 22, y: 26, r: 7 }, { x: 64, y: 14, r: 5 }, { x: 96, y: 40, r: 9 }, { x: 30, y: 70, r: 6 }, { x: 84, y: 78, r: 5 }, { x: 54, y: 52, r: 4 }];
// The opening: a hexagon that shrinks and turns as the blades close, cut out of the lens barrel.
function iris(t: number) {
const r = 40 - t * 30;
const turn = t * 50;
const hex = Array.from({ length: 6 }, (_, i) => {
const a = ((i * 60 + turn) * Math.PI) / 180;
return [56 + Math.cos(a) * r, 56 + Math.sin(a) * r];
});
const hole = `M${hex.map((p) => p.map((n) => n.toFixed(1)).join(' ')).join('L')}Z`;
// Each blade edge runs from a corner of the opening out to the rim, tangent-ish.
const seams = hex.map(([x, y], i) => {
const a = ((i * 60 + turn + 70) * Math.PI) / 180;
return `M${x.toFixed(1)} ${y.toFixed(1)}L${(56 + Math.cos(a) * 52).toFixed(1)} ${(56 + Math.sin(a) * 52).toFixed(1)}`;
});
return { hole, seams };
}
const css = `
.slider-iris { display: grid; justify-items: center; gap: 12px; width: 220px; }
.slider-iris__lens { position: relative; width: 112px; height: 112px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 4px light-dark(#dbdde1, #2e3035), 0 0 0 5px light-dark(#6d7179, #7d8087), 0 12px 22px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
/* What the lens sees: soft out-of-focus lights that sharpen as the aperture closes. */
.slider-iris__view { position: absolute; inset: 0; background: light-dark(#f3f4f7, #1a1c1f); filter: blur(calc((1 - var(--slider-iris-v)) * 4px)); }
.slider-iris__bokeh { position: absolute; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0.7; }
.slider-iris__lens svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.slider-iris__blades { fill: light-dark(#dbdde1, #2e3035); fill-rule: evenodd; }
.slider-iris__seam { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
@media (prefers-reduced-motion: reduce) {
.slider-iris__view { transition: none; }
}
.slider-iris__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-variant-numeric: tabular-nums; }
.slider-iris__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-iris__track { position: relative; width: 100%; height: 24px; }
.slider-iris__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-iris__fill { position: absolute; left: 0; top: 10px; height: 4px; border-radius: 2px; width: calc(10px + var(--slider-iris-v) * (100% - 20px)); background: light-dark(#50535b, #c6c8cd); }
.slider-iris__thumb { position: absolute; top: 2px; left: calc(var(--slider-iris-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-iris:has(.slider-iris__input:focus-visible) .slider-iris__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-iris__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-iris__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
.slider-iris__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }
`;
export function IrisSlider() {
const [v, setV] = useState(2);
const t = (v - 0) / (7 - 0);
const { hole, seams } = iris(t);
return (
<>
<style>
{css}
</style>
<div className="slider-iris" style={{ '--slider-iris-v': t } as React.CSSProperties}>
<div className="slider-iris__lens" aria-hidden="true">
<span className="slider-iris__view">
{BOKEH.map((b) => (
<span key={`${b.x}-${b.y}`} className="slider-iris__bokeh" style={{ left: b.x, top: b.y, width: b.r * 2, height: b.r * 2 }} />
))}
</span>
<svg viewBox="0 0 112 112">
<path className="slider-iris__blades" d={`M0 0H112V112H0Z${hole}`} />
{seams.map((d, i) => (
<path key={i} className="slider-iris__seam" d={d} />
))}
</svg>
</div>
<span className="slider-iris__read" aria-hidden="true">
<b>f/{STOPS[v]}</b> · {v < 3 ? 'soft background' : v < 6 ? 'balanced' : 'everything sharp'}
</span>
<div className="slider-iris__track">
<span className="slider-iris__rail" />
<span className="slider-iris__fill" />
<span className="slider-iris__thumb" />
<input
className="slider-iris__input"
type="range"
min={0}
max={7}
value={v}
onChange={(e) => setV(+e.target.value)}
aria-label="Aperture"
aria-valuetext={`f ${STOPS[v]}`}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initSliderIris(el) on the mounted element instead.
<div class="slider-iris" style="--slider-iris-v:0.2857142857142857">
<div class="slider-iris__lens" aria-hidden="true">
<span class="slider-iris__view">
<span class="slider-iris__bokeh" style="left:22px;top:26px;width:14px;height:14px"></span>
<span class="slider-iris__bokeh" style="left:64px;top:14px;width:10px;height:10px"></span>
<span class="slider-iris__bokeh" style="left:96px;top:40px;width:18px;height:18px"></span>
<span class="slider-iris__bokeh" style="left:30px;top:70px;width:12px;height:12px"></span>
<span class="slider-iris__bokeh" style="left:84px;top:78px;width:10px;height:10px"></span>
<span class="slider-iris__bokeh" style="left:54px;top:52px;width:8px;height:8px"></span>
</span>
<svg viewBox="0 0 112 112">
<path class="slider-iris__blades" d="M0 0H112V112H0ZM86.5 63.8L64.5 86.3L34.1 78.5L25.5 48.2L47.5 25.7L77.9 33.5Z"></path>
<path class="slider-iris__seam" d="M86.5 63.8L61.2 107.7"></path>
<path class="slider-iris__seam" d="M64.5 86.3L13.8 86.4"></path>
<path class="slider-iris__seam" d="M34.1 78.5L8.6 34.6"></path>
<path class="slider-iris__seam" d="M25.5 48.2L50.8 4.3"></path>
<path class="slider-iris__seam" d="M47.5 25.7L98.2 25.6"></path>
<path class="slider-iris__seam" d="M77.9 33.5L103.4 77.4"></path>
</svg>
</div>
<span class="slider-iris__read" aria-hidden="true"><b>f/2.8</b> · soft background</span>
<div class="slider-iris__track">
<span class="slider-iris__rail"></span>
<span class="slider-iris__fill"></span>
<span class="slider-iris__thumb"></span>
<input class="slider-iris__input" type="range" min="0" max="7" aria-label="Aperture" aria-valuetext="f 2.8" value="2"/>
</div>
</div>.slider-iris { display: grid; justify-items: center; gap: 12px; width: 220px; }
.slider-iris__lens { position: relative; width: 112px; height: 112px; border-radius: 50%; overflow: hidden; box-shadow: 0 0 0 4px light-dark(#dbdde1, #2e3035), 0 0 0 5px light-dark(#6d7179, #7d8087), 0 12px 22px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
/* What the lens sees: soft out-of-focus lights that sharpen as the aperture closes. */
.slider-iris__view { position: absolute; inset: 0; background: light-dark(#f3f4f7, #1a1c1f); filter: blur(calc((1 - var(--slider-iris-v)) * 4px)); }
.slider-iris__bokeh { position: absolute; border-radius: 50%; background: light-dark(#50535b, #c6c8cd); opacity: 0.7; }
.slider-iris__lens svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.slider-iris__blades { fill: light-dark(#dbdde1, #2e3035); fill-rule: evenodd; }
.slider-iris__seam { stroke: light-dark(#6d7179, #7d8087); stroke-width: 1; }
@media (prefers-reduced-motion: reduce) {
.slider-iris__view { transition: none; }
}
.slider-iris__read { color: light-dark(#50535b, #c6c8cd); font-size: 12px; font-variant-numeric: tabular-nums; }
.slider-iris__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.slider-iris__track { position: relative; width: 100%; height: 24px; }
.slider-iris__rail { position: absolute; left: 0; right: 0; top: 10px; height: 4px; border-radius: 2px; background: light-dark(#ebedf0, #232529); }
.slider-iris__fill { position: absolute; left: 0; top: 10px; height: 4px; border-radius: 2px; width: calc(10px + var(--slider-iris-v) * (100% - 20px)); background: light-dark(#50535b, #c6c8cd); }
.slider-iris__thumb { position: absolute; top: 2px; left: calc(var(--slider-iris-v) * (100% - 20px)); width: 20px; height: 20px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 2px 6px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.4)); }
.slider-iris:has(.slider-iris__input:focus-visible) .slider-iris__thumb { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 3px; }
.slider-iris__input { position: absolute; inset: 0; width: 100%; height: 100%; margin: 0; opacity: 0; cursor: pointer; -webkit-appearance: none; appearance: none; background: none; }
.slider-iris__input::-webkit-slider-thumb { -webkit-appearance: none; width: 20px; height: 20px; }
.slider-iris__input::-moz-range-thumb { width: 20px; height: 20px; border: 0; }function initSliderIris(root) {
var input = root.querySelector('.slider-iris__input');
var read = root.querySelector('.slider-iris__read');
var STOPS = [1.4, 2, 2.8, 4, 5.6, 8, 11, 16];
var blades = root.querySelector('.slider-iris__blades');
var seams = root.querySelectorAll('.slider-iris__seam');
// The opening: a hexagon that shrinks and turns as the blades close, cut out of the lens barrel.
function shape(t) {
var r = 40 - t * 30;
var turn = t * 50;
var hex = [];
for (var i = 0; i < 6; i++) {
var a = ((i * 60 + turn) * Math.PI) / 180;
hex.push([56 + Math.cos(a) * r, 56 + Math.sin(a) * r]);
}
blades.setAttribute('d', 'M0 0H112V112H0ZM' + hex.map(function (p) { return p[0].toFixed(1) + ' ' + p[1].toFixed(1); }).join('L') + 'Z');
// Each blade edge runs from a corner of the opening out to the rim.
hex.forEach(function (p, i) {
var a = ((i * 60 + turn + 70) * Math.PI) / 180;
seams[i].setAttribute('d', 'M' + p[0].toFixed(1) + ' ' + p[1].toFixed(1) + 'L' + (56 + Math.cos(a) * 52).toFixed(1) + ' ' + (56 + Math.sin(a) * 52).toFixed(1));
});
}
function update() {
var v = +input.value;
var t = (v - 0) / (7 - 0);
root.style.setProperty('--slider-iris-v', t);
shape(t);
read.innerHTML = '<b>f/' + STOPS[v] + '</b> · ' + (v < 3 ? 'soft background' : v < 6 ? 'balanced' : 'everything sharp');
input.setAttribute('aria-valuetext', 'f ' + STOPS[v]);
}
input.addEventListener('input', update);
update();
return function () { input.removeEventListener('input', update); };
}
document.querySelectorAll('.slider-iris').forEach(initSliderIris);Goal: Create a "Iris" slider.
Visual behavior: An aperture slider drawn as a lens iris: the blades close in and turn as you stop down from f/1.4 to f/16, and the soft lights behind sharpen into focus.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Each step computes a hexagonal opening, smaller and turned further as the value rises, cut out of the lens barrel as an evenodd SVG path; six seam lines run from its corners to the rim as blade edges.
- The value is written to one CSS custom property (0 to 1) on the root; the out-of-focus lights behind the iris are blurred by (1 - value) * 4px.
- :has(input:focus-visible) outlines the drawn thumb, so keyboard focus is visible though the input itself is not.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the lights sharpen without easing.
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" slider.
Visual behavior: An aperture slider drawn as a lens iris: the blades close in and turn as you stop down from f/1.4 to f/16, and the soft lights behind sharpen into focus.
Technique:
- A real <input type="range"> lies over the drawing, transparent (opacity 0, appearance none), with its thumb hit area sized to the drawn thumb, so dragging, clicking the track and the arrow, Page Up/Down, Home and End keys all work natively. The drawing is aria-hidden.
- Each step computes a hexagonal opening, smaller and turned further as the value rises, cut out of the lens barrel as an evenodd SVG path; six seam lines run from its corners to the rim as blade edges.
- The value is written to one CSS custom property (0 to 1) on the root; the out-of-focus lights behind the iris are blurred by (1 - value) * 4px.
- :has(input:focus-visible) outlines the drawn thumb, so keyboard focus is visible though the input itself is not.
Accessibility: A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the lights sharpen without easing.
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
An aperture slider drawn as a lens iris: the blades close in and turn as you stop down from f/1.4 to f/16, and the soft lights behind sharpen into focus.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- state change, focus
- Techniques
- svg, css variables, filter
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native range input with an aria-label and an aria-valuetext in words, so a screen reader announces the reading rather than a bare number; keyboard focus outlines the drawn thumb. Under prefers-reduced-motion the lights sharpen without easing.