Film
import { useEffect, useId, useRef, useState } from 'react';
const ACTS = [
{ label: 'Act I', body: 'A harbour town at dawn. Nobody has noticed the boat yet.' },
{ label: 'Act II', body: 'The boat is empty. The town argues about what that means.' },
{ label: 'Act III', body: 'A storm, a lost key and a long night at the lighthouse.' },
{ label: 'Act IV', body: 'Morning again, and the boat is gone as quietly as it came.' },
];
const PITCH = 80;
const MOVE = 150;
const HOLD = 110;
// The strip's x for a frame centred in the gate.
const xOf = (f: number) => 80 - f * PITCH;
const css = `
.tabs-film { width: 240px; }
.tabs-film__view { position: relative; height: 76px; overflow: hidden; border-radius: 6px; }
.tabs-film__strip {
position: absolute;
left: 0;
top: 0;
display: flex;
gap: 8px;
height: 76px;
padding: 13px 4px;
box-sizing: border-box;
background:
repeating-linear-gradient(90deg, light-dark(#fbfcfd, #121417) 0 6px, transparent 6px 14px) 3px 4px / 100% 5px no-repeat,
repeating-linear-gradient(90deg, light-dark(#fbfcfd, #121417) 0 6px, transparent 6px 14px) 3px 67px / 100% 5px no-repeat,
light-dark(#ebedf0, #232529);
}
.tabs-film__frame { flex: none; width: 72px; height: 50px; border-radius: 2px; overflow: hidden; }
.tabs-film__frame svg { display: block; width: 100%; height: 100%; }
.tabs-film__bg { fill: light-dark(#dbdde1, #2e3035); }
.tabs-film__a { fill: light-dark(#6d7179, #7d8087); }
.tabs-film__b { fill: light-dark(#50535b, #c6c8cd); }
/* Outside the gate the strip is in shadow; the gate is the lit frame. */
.tabs-film__dim { position: absolute; top: 0; bottom: 0; width: 82px; background: color-mix(in srgb, light-dark(#fbfcfd, #121417) 62%, transparent); pointer-events: none; }
.tabs-film__gate { position: absolute; left: 82px; top: 11px; width: 76px; height: 54px; box-sizing: border-box; border: 2px solid light-dark(#373a40, #f1f2f3); border-radius: 4px; pointer-events: none; }
.tabs-film__shutter { position: absolute; inset: 0; background: light-dark(#fbfcfd, #121417); opacity: 0; }
.tabs-film__list { display: grid; grid-template-columns: repeat(${ACTS.length}, 1fr); margin-top: 8px; }
.tabs-film__tab { border: 0; padding: 6px 0; background: none; font: inherit; font-size: 12.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-film__tab:hover, .tabs-film__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-film__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-film__panel { padding: 6px 0 0; min-height: calc(3.1em + 6px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
function Scene({ i }: { i: number }) {
return (
<svg viewBox="0 0 72 50" aria-hidden="true">
<rect className="tabs-film__bg" width={72} height={50} />
{i === 0 && (
<>
<circle className="tabs-film__b" cx={50} cy={30} r={9} />
<rect className="tabs-film__a" y={34} width={72} height={16} />
</>
)}
{i === 1 && (
<>
<rect className="tabs-film__a" y={36} width={72} height={14} />
<path className="tabs-film__b" d="M24 34H48L44 40H28Z M36 34V16L46 30Z" />
</>
)}
{i === 2 && (
<>
<rect className="tabs-film__a" x={30} y={14} width={10} height={36} />
<path className="tabs-film__b" d="M35 14L72 4V22Z" />
</>
)}
{i === 3 && (
<>
<circle className="tabs-film__b" cx={20} cy={16} r={6} />
<rect className="tabs-film__a" y={36} width={72} height={14} />
</>
)}
</svg>
);
}
export function FilmTabs() {
const id = useId();
const [sel, setSel] = useState(0);
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const strip = useRef<HTMLDivElement>(null);
const shutter = useRef<HTMLSpanElement>(null);
// A projector moves film in steps: the strip jumps one frame, holds, jumps again, and the shutter
// closes over the gate during each pull so only still frames are seen.
useEffect(() => {
const el = strip.current!;
const x0 = new DOMMatrix(getComputedStyle(el).transform).e;
el.getAnimations().forEach((a) => a.cancel());
shutter.current!.getAnimations().forEach((a) => a.cancel());
const x1 = xOf(sel);
el.style.transform = `translateX(${x1}px)`;
const steps = Math.ceil(Math.abs(x1 - x0) / PITCH - 0.01);
if (!steps || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
const dir = Math.sign(x1 - x0);
const total = steps * (MOVE + HOLD) - HOLD;
const frames: Keyframe[] = [];
const blinks: Keyframe[] = [];
let t = 0;
let x = x0;
for (let k = 0; k < steps; k++) {
const nx = k === steps - 1 ? x1 : xOf(Math.round((80 - x) / PITCH) - dir);
frames.push({ transform: `translateX(${x}px)`, offset: t / total, easing: 'cubic-bezier(0.6, 0, 0.4, 1)' });
blinks.push({ opacity: 0, offset: t / total }, { opacity: 0.85, offset: (t + MOVE * 0.25) / total }, { opacity: 0.85, offset: (t + MOVE * 0.75) / total });
t += MOVE;
frames.push({ transform: `translateX(${nx}px)`, offset: t / total });
blinks.push({ opacity: 0, offset: t / total });
t += HOLD;
x = nx;
}
el.animate(frames, { duration: total });
shutter.current!.animate(blinks, { duration: total });
}, [sel]);
const n = ACTS.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-film">
<div className="tabs-film__view" aria-hidden="true">
<div className="tabs-film__strip" ref={strip} style={{ transform: `translateX(${xOf(0)}px)` }}>
{ACTS.map((a, i) => (
<span key={a.label} className="tabs-film__frame">
<Scene i={i} />
</span>
))}
</div>
<span className="tabs-film__dim" style={{ left: 0 }} />
<span className="tabs-film__dim" style={{ right: 0 }} />
<span className="tabs-film__gate">
<span className="tabs-film__shutter" ref={shutter} />
</span>
</div>
<div className="tabs-film__list" role="tablist" aria-label="Acts" onKeyDown={onKeyDown}>
{ACTS.map((a, i) => (
<button
key={a.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-film__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => setSel(i)}
>
{a.label}
</button>
))}
</div>
{ACTS.map((a, i) => (
<div key={a.label} className="tabs-film__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
{a.body}
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsFilm(el) on the mounted element instead.
<div class="tabs-film">
<div class="tabs-film__view" aria-hidden="true">
<div class="tabs-film__strip" style="transform:translateX(80px)">
<span class="tabs-film__frame">
<svg viewBox="0 0 72 50" aria-hidden="true">
<rect class="tabs-film__bg" width="72" height="50"></rect>
<circle class="tabs-film__b" cx="50" cy="30" r="9"></circle>
<rect class="tabs-film__a" y="34" width="72" height="16"></rect>
</svg>
</span>
<span class="tabs-film__frame">
<svg viewBox="0 0 72 50" aria-hidden="true">
<rect class="tabs-film__bg" width="72" height="50"></rect>
<rect class="tabs-film__a" y="36" width="72" height="14"></rect>
<path class="tabs-film__b" d="M24 34H48L44 40H28Z M36 34V16L46 30Z"></path>
</svg>
</span>
<span class="tabs-film__frame">
<svg viewBox="0 0 72 50" aria-hidden="true">
<rect class="tabs-film__bg" width="72" height="50"></rect>
<rect class="tabs-film__a" x="30" y="14" width="10" height="36"></rect>
<path class="tabs-film__b" d="M35 14L72 4V22Z"></path>
</svg>
</span>
<span class="tabs-film__frame">
<svg viewBox="0 0 72 50" aria-hidden="true">
<rect class="tabs-film__bg" width="72" height="50"></rect>
<circle class="tabs-film__b" cx="20" cy="16" r="6"></circle>
<rect class="tabs-film__a" y="36" width="72" height="14"></rect>
</svg>
</span>
</div>
<span class="tabs-film__dim" style="left:0"></span>
<span class="tabs-film__dim" style="right:0"></span>
<span class="tabs-film__gate">
<span class="tabs-film__shutter"></span>
</span>
</div>
<div class="tabs-film__list" role="tablist" aria-label="Acts">
<button class="tabs-film__tab" type="button" role="tab" id="tabs-film-tab-0" aria-selected="true" aria-controls="tabs-film-panel-0" tabindex="0">Act I</button>
<button class="tabs-film__tab" type="button" role="tab" id="tabs-film-tab-1" aria-selected="false" aria-controls="tabs-film-panel-1" tabindex="-1">Act II</button>
<button class="tabs-film__tab" type="button" role="tab" id="tabs-film-tab-2" aria-selected="false" aria-controls="tabs-film-panel-2" tabindex="-1">Act III</button>
<button class="tabs-film__tab" type="button" role="tab" id="tabs-film-tab-3" aria-selected="false" aria-controls="tabs-film-panel-3" tabindex="-1">Act IV</button>
</div>
<div class="tabs-film__panel" role="tabpanel" id="tabs-film-panel-0" aria-labelledby="tabs-film-tab-0">A harbour town at dawn. Nobody has noticed the boat yet.</div>
<div class="tabs-film__panel" role="tabpanel" id="tabs-film-panel-1" aria-labelledby="tabs-film-tab-1" hidden="">The boat is empty. The town argues about what that means.</div>
<div class="tabs-film__panel" role="tabpanel" id="tabs-film-panel-2" aria-labelledby="tabs-film-tab-2" hidden="">A storm, a lost key and a long night at the lighthouse.</div>
<div class="tabs-film__panel" role="tabpanel" id="tabs-film-panel-3" aria-labelledby="tabs-film-tab-3" hidden="">Morning again, and the boat is gone as quietly as it came.</div>
</div>.tabs-film { width: 240px; }
.tabs-film__view { position: relative; height: 76px; overflow: hidden; border-radius: 6px; }
.tabs-film__strip {
position: absolute;
left: 0;
top: 0;
display: flex;
gap: 8px;
height: 76px;
padding: 13px 4px;
box-sizing: border-box;
background:
repeating-linear-gradient(90deg, light-dark(#fbfcfd, #121417) 0 6px, transparent 6px 14px) 3px 4px / 100% 5px no-repeat,
repeating-linear-gradient(90deg, light-dark(#fbfcfd, #121417) 0 6px, transparent 6px 14px) 3px 67px / 100% 5px no-repeat,
light-dark(#ebedf0, #232529);
}
.tabs-film__frame { flex: none; width: 72px; height: 50px; border-radius: 2px; overflow: hidden; }
.tabs-film__frame svg { display: block; width: 100%; height: 100%; }
.tabs-film__bg { fill: light-dark(#dbdde1, #2e3035); }
.tabs-film__a { fill: light-dark(#6d7179, #7d8087); }
.tabs-film__b { fill: light-dark(#50535b, #c6c8cd); }
/* Outside the gate the strip is in shadow; the gate is the lit frame. */
.tabs-film__dim { position: absolute; top: 0; bottom: 0; width: 82px; background: color-mix(in srgb, light-dark(#fbfcfd, #121417) 62%, transparent); pointer-events: none; }
.tabs-film__gate { position: absolute; left: 82px; top: 11px; width: 76px; height: 54px; box-sizing: border-box; border: 2px solid light-dark(#373a40, #f1f2f3); border-radius: 4px; pointer-events: none; }
.tabs-film__shutter { position: absolute; inset: 0; background: light-dark(#fbfcfd, #121417); opacity: 0; }
.tabs-film__list { display: grid; grid-template-columns: repeat(4, 1fr); margin-top: 8px; }
.tabs-film__tab { border: 0; padding: 6px 0; background: none; font: inherit; font-size: 12.5px; font-weight: 500; color: light-dark(#5f626a, #a7abb2); cursor: pointer; transition: color 0.25s; }
.tabs-film__tab:hover, .tabs-film__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); }
.tabs-film__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: -2px; border-radius: 6px; }
.tabs-film__panel { padding: 6px 0 0; min-height: calc(3.1em + 6px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsFilm(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = Array.prototype.slice.call(root.querySelectorAll('[role="tabpanel"]'));
var n = tabs.length;
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var PITCH = 80;
var MOVE = 150;
var HOLD = 110;
var strip = root.querySelector('.tabs-film__strip');
var shutter = root.querySelector('.tabs-film__shutter');
function xOf(f) { return 80 - f * PITCH; }
// A projector moves film in steps: the strip jumps one frame, holds, jumps again, and the shutter
// closes over the gate during each pull so only still frames are seen.
function advance() {
var x0 = new DOMMatrix(getComputedStyle(strip).transform).e;
strip.getAnimations().forEach(function (a) { a.cancel(); });
shutter.getAnimations().forEach(function (a) { a.cancel(); });
var x1 = xOf(sel);
strip.style.transform = 'translateX(' + x1 + 'px)';
var steps = Math.ceil(Math.abs(x1 - x0) / PITCH - 0.01);
if (!steps || matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var dir = Math.sign(x1 - x0);
var total = steps * (MOVE + HOLD) - HOLD;
var frames = [];
var blinks = [];
var t = 0;
var x = x0;
for (var k = 0; k < steps; k++) {
var nx = k === steps - 1 ? x1 : xOf(Math.round((80 - x) / PITCH) - dir);
frames.push({ transform: 'translateX(' + x + 'px)', offset: t / total, easing: 'cubic-bezier(0.6, 0, 0.4, 1)' });
blinks.push({ opacity: 0, offset: t / total }, { opacity: 0.85, offset: (t + MOVE * 0.25) / total }, { opacity: 0.85, offset: (t + MOVE * 0.75) / total });
t += MOVE;
frames.push({ transform: 'translateX(' + nx + 'px)', offset: t / total });
blinks.push({ opacity: 0, offset: t / total });
t += HOLD;
x = nx;
}
strip.animate(frames, { duration: total });
shutter.animate(blinks, { duration: total });
}
function select(i) {
if (i === sel) return;
var prev = sel;
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; });
advance();
}
function onClick(e) {
var tab = e.target.closest('[role="tab"]');
if (tab) select(tabs.indexOf(tab));
}
function onKeyDown(e) {
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 () {
strip.getAnimations().forEach(function (a) { a.cancel(); });
shutter.getAnimations().forEach(function (a) { a.cancel(); });
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-film').forEach(initTabsFilm);Goal: Create a "Film" tab set.
Visual behavior: Acts on a strip of film: the strip advances through the gate in steps, a jump and a hold per frame, with the shutter blinking over each pull.
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.
- Switching builds one Web Animation for the strip with a pull and a hold per frame between the old act and the new one, so a long jump steps through every frame between.
- A matching animation on the shutter closes it over the gate during each pull, so only still frames are seen.
- A switch mid-way reads the strip's current offset and steps on from there.
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. Decoration is aria-hidden. Under prefers-reduced-motion the strip moves straight to the act.
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 "Film" tab set.
Visual behavior: Acts on a strip of film: the strip advances through the gate in steps, a jump and a hold per frame, with the shutter blinking over each pull.
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.
- Switching builds one Web Animation for the strip with a pull and a hold per frame between the old act and the new one, so a long jump steps through every frame between.
- A matching animation on the shutter closes it over the gate during each pull, so only still frames are seen.
- A switch mid-way reads the strip's current offset and steps on from there.
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. Decoration is aria-hidden. Under prefers-reduced-motion the strip moves straight to the act.
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
Acts on a strip of film: the strip advances through the gate in steps, a jump and a hold per frame, with the shutter blinking over each pull.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change, keyframe
- Techniques
- keyframes, transform only, svg
- 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. Decoration is aria-hidden. Under prefers-reduced-motion the strip moves straight to the act.