Shutter
import { useEffect, useId, useRef, useState } from 'react';
const TABS = [
{
label: 'Photo',
body: 'Full resolution stills, with live focus peaking.',
icon: (
<>
<path d="M5 9h3l1.5-2h5L16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1Z" />
<circle cx="12" cy="13.5" r="3" />
</>
),
},
{
label: 'Video',
body: '4K at 24 or 60 frames, with the shutter at 180 degrees.',
icon: (
<>
<path d="M4 8.5h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z" />
<path d="M15 11.5 20 9v8l-5-2.5" />
</>
),
},
{
label: 'Portrait',
body: 'Soft background blur from f/1.4, adjustable after the shot.',
icon: (
<>
<circle cx="12" cy="9.5" r="3.2" />
<path d="M5.5 19c.8-3.4 3.3-5 6.5-5s5.7 1.6 6.5 5" />
</>
),
},
{ label: 'Pano', body: 'Sweep slowly from left to right; the level keeps you straight.', icon: <path d="M3 8c6 1.6 12 1.6 18 0v9c-6-1.6-12-1.6-18 0Z" /> },
];
const R = 20;
const C = 22;
const ease = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);
/** The blades as one shape: the disc with a turning hexagonal hole, plus the seams between the six blades. */
function iris(a: number) {
const h = R * 1.3 * (1 - a);
const rot = (a * Math.PI) / 3;
const v = Array.from({ length: 6 }, (_, k) => [C + h * Math.cos(rot + (k * Math.PI) / 3), C + h * Math.sin(rot + (k * Math.PI) / 3)]);
const hole = `M${v.map((p) => p.join(' ')).join('L')}Z`;
const seams = v.map(([x, y], k) => {
const [nx, ny] = v[(k + 1) % 6];
const len = Math.hypot(nx - x, ny - y) || 1;
return `M${x} ${y}L${x + ((nx - x) / len) * R * 2.2} ${y + ((ny - y) / len) * R * 2.2}`;
});
return { d: `M${C - R - 2} ${C}a${R + 2} ${R + 2} 0 1 0 ${2 * R + 4} 0a${R + 2} ${R + 2} 0 1 0 ${-2 * R - 4} 0Z${hole}`, seams: seams.join('') };
}
const css = `
.tabs-shutter { width: 260px; }
.tabs-shutter__list { display: flex; justify-content: center; gap: 14px; }
.tabs-shutter__tab { position: relative; width: 44px; height: 44px; border: 0; padding: 0; border-radius: 50%; background: none; cursor: pointer; }
.tabs-shutter__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.tabs-shutter__svg { display: block; overflow: visible; }
.tabs-shutter__rim { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; }
.tabs-shutter__blades { fill: light-dark(#373a40, #f1f2f3); fill-rule: evenodd; }
.tabs-shutter__seams { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; }
.tabs-shutter__icon { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; transition: stroke 0.2s; }
.tabs-shutter__tab:hover .tabs-shutter__icon { stroke: light-dark(#373a40, #f1f2f3); }
.tabs-shutter__tab--closed .tabs-shutter__icon, .tabs-shutter__tab--closed:hover .tabs-shutter__icon { stroke: light-dark(#fbfcfd, #121417); }
.tabs-shutter__name { margin-top: 10px; text-align: center; font-size: 12px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: light-dark(#373a40, #f1f2f3); }
.tabs-shutter__panel { padding: 6px 4px 0; min-height: calc(3.1em + 6px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }
`;
export function ShutterTabs() {
const id = useId().replace(/[^\w-]/g, '');
const [sel, setSel] = useState(0);
const [amt, setAmt] = useState<number[]>(() => TABS.map((_, i) => (i === sel ? 1 : 0)));
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const live = useRef(amt);
live.current = amt;
// The new tab's blades close and the old tab's open, over the same short beat.
useEffect(() => {
const from = live.current;
const to = TABS.map((_, i) => (i === sel ? 1 : 0));
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return setAmt(to);
const start = performance.now();
let raf = 0;
const step = (now: number) => {
const t = Math.min(1, (now - start) / 420);
setAmt(from.map((a, i) => a + (to[i] - a) * ease(t)));
if (t < 1) raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(raf);
}, [sel]);
const n = TABS.length;
const onKeyDown = (e: React.KeyboardEvent) => {
const next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
setSel(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-shutter">
<div className="tabs-shutter__list" role="tablist" aria-label="Camera mode" onKeyDown={onKeyDown}>
{TABS.map((t, i) => {
const a = amt[i];
const blades = a > 0.001 ? iris(a) : null;
return (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className={`tabs-shutter__tab${a > 0.6 ? ' tabs-shutter__tab--closed' : ''}`}
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-label={t.label}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
<svg className="tabs-shutter__svg" width={44} height={44} aria-hidden="true">
<clipPath id={`${id}-lens-${i}`}>
<circle cx={C} cy={C} r={R} />
</clipPath>
<circle className="tabs-shutter__rim" cx={C} cy={C} r={R} />
{blades && (
<g clipPath={`url(#${id}-lens-${i})`}>
<path className="tabs-shutter__blades" d={blades.d} />
<path className="tabs-shutter__seams" d={blades.seams} style={{ opacity: Math.min(1, (1 - a) * 4) }} />
</g>
)}
<g className="tabs-shutter__icon" transform={`translate(${C - 12} ${C - 12})`}>
{t.icon}
</g>
</svg>
</button>
);
})}
</div>
<p className="tabs-shutter__name" aria-hidden="true">
{TABS[sel].label}
</p>
{TABS.map((t, i) => (
<div key={t.label} className="tabs-shutter__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{t.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsShutter(el) on the mounted element instead.
<div class="tabs-shutter">
<div class="tabs-shutter__list" role="tablist" aria-label="Camera mode">
<button class="tabs-shutter__tab tabs-shutter__tab--closed" type="button" role="tab" id="tabs-shutter-tab-0" aria-label="Photo" aria-selected="true" aria-controls="tabs-shutter-panel-0" tabindex="0">
<svg class="tabs-shutter__svg" width="44" height="44" aria-hidden="true">
<clipPath id="tabs-shutter-lens-0">
<circle cx="22" cy="22" r="20"></circle>
</clipPath>
<circle class="tabs-shutter__rim" cx="22" cy="22" r="20"></circle>
<g clip-path="url(#tabs-shutter-lens-0)">
<path class="tabs-shutter__blades" d="M0 22a22 22 0 1 0 44 0a22 22 0 1 0 -44 0ZM22 22L22 22L22 22L22 22L22 22L22 22Z"></path>
<path class="tabs-shutter__seams" d="M22 22L22 22M22 22L22 22M22 22L22 22M22 22L22 22M22 22L22 22M22 22L22 22" style="opacity:0"></path>
</g>
<g class="tabs-shutter__icon" transform="translate(10 10)">
<path d="M5 9h3l1.5-2h5L16 9h3a1 1 0 0 1 1 1v8a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1v-8a1 1 0 0 1 1-1Z"></path>
<circle cx="12" cy="13.5" r="3"></circle>
</g>
</svg>
</button>
<button class="tabs-shutter__tab" type="button" role="tab" id="tabs-shutter-tab-1" aria-label="Video" aria-selected="false" aria-controls="tabs-shutter-panel-1" tabindex="-1">
<svg class="tabs-shutter__svg" width="44" height="44" aria-hidden="true">
<clipPath id="tabs-shutter-lens-1">
<circle cx="22" cy="22" r="20"></circle>
</clipPath>
<circle class="tabs-shutter__rim" cx="22" cy="22" r="20"></circle>
<g class="tabs-shutter__icon" transform="translate(10 10)">
<path d="M4 8.5h10a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1Z"></path>
<path d="M15 11.5 20 9v8l-5-2.5"></path>
</g>
</svg>
</button>
<button class="tabs-shutter__tab" type="button" role="tab" id="tabs-shutter-tab-2" aria-label="Portrait" aria-selected="false" aria-controls="tabs-shutter-panel-2" tabindex="-1">
<svg class="tabs-shutter__svg" width="44" height="44" aria-hidden="true">
<clipPath id="tabs-shutter-lens-2">
<circle cx="22" cy="22" r="20"></circle>
</clipPath>
<circle class="tabs-shutter__rim" cx="22" cy="22" r="20"></circle>
<g class="tabs-shutter__icon" transform="translate(10 10)">
<circle cx="12" cy="9.5" r="3.2"></circle>
<path d="M5.5 19c.8-3.4 3.3-5 6.5-5s5.7 1.6 6.5 5"></path>
</g>
</svg>
</button>
<button class="tabs-shutter__tab" type="button" role="tab" id="tabs-shutter-tab-3" aria-label="Pano" aria-selected="false" aria-controls="tabs-shutter-panel-3" tabindex="-1">
<svg class="tabs-shutter__svg" width="44" height="44" aria-hidden="true">
<clipPath id="tabs-shutter-lens-3">
<circle cx="22" cy="22" r="20"></circle>
</clipPath>
<circle class="tabs-shutter__rim" cx="22" cy="22" r="20"></circle>
<g class="tabs-shutter__icon" transform="translate(10 10)">
<path d="M3 8c6 1.6 12 1.6 18 0v9c-6-1.6-12-1.6-18 0Z"></path>
</g>
</svg>
</button>
</div>
<p class="tabs-shutter__name" aria-hidden="true">Photo</p>
<div class="tabs-shutter__panel" role="tabpanel" id="tabs-shutter-panel-0" aria-labelledby="tabs-shutter-tab-0">Full resolution stills, with live focus peaking.</div>
<div class="tabs-shutter__panel" role="tabpanel" id="tabs-shutter-panel-1" aria-labelledby="tabs-shutter-tab-1" hidden="">4K at 24 or 60 frames, with the shutter at 180 degrees.</div>
<div class="tabs-shutter__panel" role="tabpanel" id="tabs-shutter-panel-2" aria-labelledby="tabs-shutter-tab-2" hidden="">Soft background blur from f/1.4, adjustable after the shot.</div>
<div class="tabs-shutter__panel" role="tabpanel" id="tabs-shutter-panel-3" aria-labelledby="tabs-shutter-tab-3" hidden="">Sweep slowly from left to right; the level keeps you straight.</div>
</div>.tabs-shutter { width: 260px; }
.tabs-shutter__list { display: flex; justify-content: center; gap: 14px; }
.tabs-shutter__tab { position: relative; width: 44px; height: 44px; border: 0; padding: 0; border-radius: 50%; background: none; cursor: pointer; }
.tabs-shutter__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 3px; }
.tabs-shutter__svg { display: block; overflow: visible; }
.tabs-shutter__rim { fill: none; stroke: light-dark(#dbdde1, #2e3035); stroke-width: 1.5; }
.tabs-shutter__blades { fill: light-dark(#373a40, #f1f2f3); fill-rule: evenodd; }
.tabs-shutter__seams { fill: none; stroke: light-dark(#5f626a, #a7abb2); stroke-width: 0.8; }
.tabs-shutter__icon { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; transition: stroke 0.2s; }
.tabs-shutter__tab:hover .tabs-shutter__icon { stroke: light-dark(#373a40, #f1f2f3); }
.tabs-shutter__tab--closed .tabs-shutter__icon, .tabs-shutter__tab--closed:hover .tabs-shutter__icon { stroke: light-dark(#fbfcfd, #121417); }
.tabs-shutter__name { margin-top: 10px; text-align: center; font-size: 12px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: light-dark(#373a40, #f1f2f3); }
.tabs-shutter__panel { padding: 6px 4px 0; min-height: calc(3.1em + 6px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; text-align: center; }function initTabsShutter(root) {
var R = 20;
var C = 22;
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var name = root.querySelector('.tabs-shutter__name');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var amt = tabs.map(function (_, i) { return i === sel ? 1 : 0; });
var raf = 0;
function ease(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; }
/** The blades as one shape: the disc with a turning hexagonal hole, plus the seams between the six blades. */
function iris(a) {
var h = R * 1.3 * (1 - a);
var rot = (a * Math.PI) / 3;
var v = [];
for (var k = 0; k < 6; k++) v.push([C + h * Math.cos(rot + (k * Math.PI) / 3), C + h * Math.sin(rot + (k * Math.PI) / 3)]);
var seams = v.map(function (p, k) {
var n = v[(k + 1) % 6];
var len = Math.hypot(n[0] - p[0], n[1] - p[1]) || 1;
return 'M' + p[0] + ' ' + p[1] + 'L' + (p[0] + ((n[0] - p[0]) / len) * R * 2.2) + ' ' + (p[1] + ((n[1] - p[1]) / len) * R * 2.2);
});
var hole = 'M' + v.map(function (p) { return p.join(' '); }).join('L') + 'Z';
var disc = 'M' + (C - R - 2) + ' ' + C + 'a' + (R + 2) + ' ' + (R + 2) + ' 0 1 0 ' + (2 * R + 4) + ' 0a' + (R + 2) + ' ' + (R + 2) + ' 0 1 0 ' + (-2 * R - 4) + ' 0Z';
return { d: disc + hole, seams: seams.join('') };
}
function draw() {
tabs.forEach(function (tab, i) {
var a = amt[i];
var g = tab.querySelector('g[clip-path]');
// The blades only exist while they are at least partly closed.
if (a > 0.001) {
var b = iris(a);
if (!g) {
g = document.createElementNS('http://www.w3.org/2000/svg', 'g');
g.setAttribute('clip-path', 'url(#tabs-shutter-lens-' + i + ')');
g.innerHTML = '<path class="tabs-shutter__blades"></path><path class="tabs-shutter__seams"></path>';
tab.querySelector('.tabs-shutter__rim').after(g);
}
g.firstElementChild.setAttribute('d', b.d);
g.lastElementChild.setAttribute('d', b.seams);
g.lastElementChild.style.opacity = Math.min(1, (1 - a) * 4);
} else if (g) {
g.remove();
}
tab.classList.toggle('tabs-shutter__tab--closed', a > 0.6);
});
}
// The new tab's blades close and the old tab's open, over the same short beat.
function select(i) {
if (i === sel) return;
sel = i;
tabs.forEach(function (tab, k) {
tab.setAttribute('aria-selected', String(k === i));
tab.tabIndex = k === i ? 0 : -1;
});
panels.forEach(function (panel, k) { panel.hidden = k !== i; });
name.textContent = tabs[i].getAttribute('aria-label');
cancelAnimationFrame(raf);
var from = amt.slice();
var to = tabs.map(function (_, k) { return k === i ? 1 : 0; });
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
amt = to;
return draw();
}
var start = performance.now();
function step(now) {
var t = Math.min(1, (now - start) / 420);
amt = from.map(function (a, k) { return a + (to[k] - a) * ease(t); });
draw();
if (t < 1) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
var n = tabs.length;
var next = { ArrowRight: (sel + 1) % n, ArrowLeft: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs[next].focus();
}
list.addEventListener('click', onClick);
list.addEventListener('keydown', onKeyDown);
return function () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-shutter').forEach(initTabsShutter);Goal: Create a "Shutter" tab set.
Visual behavior: Camera mode tabs as icon buttons: the active one fills as six iris blades close over it, and they open again on the tab you left.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- Each button's blades are one SVG path, a disc with a hexagonal hole cut out by the even-odd rule, clipped to the lens circle. As the iris closes, the hole shrinks to nothing while turning a sixth of a turn, and lines along the hexagon's edges draw the seams between blades.
- A requestAnimationFrame tween closes the new tab's iris while opening the old one's. The icon flips to the inverse colour once the blades cover most of it.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Each icon tab is named with aria-label, and the active mode is also written out below the row. Decoration is aria-hidden. Under prefers-reduced-motion the iris opens and closes at once.
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 "Shutter" tab set.
Visual behavior: Camera mode tabs as icon buttons: the active one fills as six iris blades close over it, and they open again on the tab you left.
Technique:
- The ARIA tabs pattern: a role="tablist" with an aria-label, buttons with role="tab", aria-selected and aria-controls, and role="tabpanel" panels labelled by their tab. Only the selected tab is in the Tab order (roving tabindex); the arrow keys, Home and End move between tabs and select as they go.
- Each button's blades are one SVG path, a disc with a hexagonal hole cut out by the even-odd rule, clipped to the lens circle. As the iris closes, the hole shrinks to nothing while turning a sixth of a turn, and lines along the hexagon's edges draw the seams between blades.
- A requestAnimationFrame tween closes the new tab's iris while opening the old one's. The icon flips to the inverse colour once the blades cover most of it.
Accessibility: Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Each icon tab is named with aria-label, and the active mode is also written out below the row. Decoration is aria-hidden. Under prefers-reduced-motion the iris opens and closes at once.
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
Camera mode tabs as icon buttons: the active one fills as six iris blades close over it, and they open again on the tab you left.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, icon, morph
- Techniques
- svg, raf, clip path
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
Real ARIA tabs: arrow keys, Home and End move between tabs, only the selected tab is in the Tab order, and each panel is labelled by its tab. Each icon tab is named with aria-label, and the active mode is also written out below the row. Decoration is aria-hidden. Under prefers-reduced-motion the iris opens and closes at once.