Cassette
import { useEffect, useId, useRef, useState } from 'react';
const TRACKS = [
{ label: 'Intro', at: 0.04, body: 'Tape hiss, a count-in and the first chord. Forty seconds long.' },
{ label: 'Tides', at: 0.33, body: 'The single. Slow drums under a guitar that never quite resolves.' },
{ label: 'Night', at: 0.62, body: 'Recorded live in one take, with the rain on the studio roof.' },
{ label: 'Outro', at: 0.9, body: 'The intro played backwards, fading into the run-out.' },
];
const TAPE = 900;
const L = { x: 78, y: 72 };
const R = { x: 142, y: 72 };
// The tape packs grow and shrink as tape winds from one reel to the other.
const packL = (p: number) => 11 + 19 * (1 - p);
const packR = (p: number) => 11 + 19 * p;
const css = `
.tabs-cassette { width: 240px; }
.tabs-cassette__deck { display: block; width: 220px; height: 120px; margin: 0 auto; }
.tabs-cassette__body { fill: light-dark(#dbdde1, #2e3035); }
.tabs-cassette__label { fill: light-dark(#42454c, #e5e6e9); }
.tabs-cassette__title { fill: light-dark(#f3f4f7, #1a1c1f); font-size: 11px; font-weight: 700; }
.tabs-cassette__side { fill: light-dark(#cbced3, #393c41); font-size: 8px; font-weight: 700; letter-spacing: 0.08em; }
.tabs-cassette__window { fill: light-dark(#fbfcfd, #121417); }
.tabs-cassette__pack { fill: light-dark(#cbced3, #393c41); }
.tabs-cassette__hub { fill: light-dark(#42454c, #e5e6e9); }
.tabs-cassette__spoke { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 2.5; stroke-linecap: round; }
.tabs-cassette__tape { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
.tabs-cassette__foot { fill: light-dark(#cbced3, #393c41); }
.tabs-cassette__read { display: flex; justify-content: space-between; margin: 6px 10px 8px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.tabs-cassette__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.tabs-cassette__list { display: grid; grid-template-columns: repeat(${TRACKS.length}, 1fr); gap: 4px; }
.tabs-cassette__tab { height: 28px; border: 0; border-radius: 7px; background: light-dark(#ebedf0, #232529); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-cassette__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-cassette__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-cassette__panel { padding: 10px 2px 0; min-height: calc(3.1em + 10px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }
`;
function Reel({ c, r, a, clip }: { c: { x: number; y: number }; r: number; a: number; clip: string }) {
return (
<g>
<circle className="tabs-cassette__pack" cx={c.x} cy={c.y} r={r} clipPath={`url(#${clip})`} />
<circle className="tabs-cassette__hub" cx={c.x} cy={c.y} r={9} />
<g transform={`rotate(${a} ${c.x} ${c.y})`}>
{[0, 120, 240].map((d) => (
<line key={d} className="tabs-cassette__spoke" x1={c.x} y1={c.y} x2={c.x} y2={c.y - 6} transform={`rotate(${d} ${c.x} ${c.y})`} />
))}
</g>
</g>
);
}
export function CassetteTabs() {
const id = useId();
const clip = `tabs-cassette-${id.replace(/[^a-zA-Z0-9]/g, '')}`;
const [sel, setSel] = useState(0);
const [deck, setDeck] = useState({ p: TRACKS[0].at, aL: 0, aR: 0, mode: 'Playing' });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const st = useRef({ p: TRACKS[0].at, aL: 0, aR: 0, raf: 0 });
// Winding to a track runs the reels fast, forward or back. Each reel turns at the speed the tape
// leaves or reaches it, so the nearly empty one spins hardest; the counter follows the tape.
useEffect(() => {
const s = st.current;
const from = s.p;
const to = TRACKS[sel].at;
cancelAnimationFrame(s.raf);
if (from === to) return;
const mode = to > from ? 'Fast forward' : 'Rewind';
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s.p = to;
return setDeck({ p: to, aL: s.aL, aR: s.aR, mode: 'Playing' });
}
const dur = 350 + 1300 * Math.abs(to - from);
const t0 = performance.now();
const step = (now: number) => {
const k = Math.min(1, (now - t0) / dur);
const e = k < 0.5 ? 4 * k ** 3 : 1 - (-2 * k + 2) ** 3 / 2;
const p = from + (to - from) * e;
const dp = p - s.p;
s.aL += ((dp * TAPE) / packL(p)) * (180 / Math.PI);
s.aR += ((dp * TAPE) / packR(p)) * (180 / Math.PI);
s.p = p;
setDeck({ p, aL: s.aL, aR: s.aR, mode: k < 1 ? mode : 'Playing' });
if (k < 1) s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
return () => cancelAnimationFrame(s.raf);
}, [sel]);
const rl = packL(deck.p);
const rr = packR(deck.p);
const n = TRACKS.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-cassette">
<svg className="tabs-cassette__deck" viewBox="0 0 220 120" aria-hidden="true">
<defs>
<clipPath id={clip}>
<rect x={48} y={52} width={124} height={40} rx={6} />
</clipPath>
</defs>
<rect className="tabs-cassette__body" x={4} y={4} width={212} height={112} rx={8} />
<rect className="tabs-cassette__label" x={16} y={12} width={188} height={32} rx={3} />
<text className="tabs-cassette__side" x={24} y={24}>
SIDE A
</text>
<text className="tabs-cassette__title" x={24} y={37}>
{TRACKS[sel].label}
</text>
<rect className="tabs-cassette__window" x={48} y={52} width={124} height={40} rx={6} />
<path className="tabs-cassette__tape" d={`M${L.x} ${L.y + rl}L36 106H184L${R.x} ${R.y + rr}`} />
<Reel c={L} r={rl} a={deck.aL} clip={clip} />
<Reel c={R} r={rr} a={deck.aR} clip={clip} />
<path className="tabs-cassette__foot" d="M44 116L54 104H166L176 116Z" />
</svg>
<p className="tabs-cassette__read" aria-hidden="true">
<b>{deck.mode}</b>
<span>{String(Math.round(deck.p * 600)).padStart(3, '0')}</span>
</p>
<div className="tabs-cassette__list" role="tablist" aria-label="Side A" onKeyDown={onKeyDown}>
{TRACKS.map((t, i) => (
<button
key={t.label}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-cassette__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)}
>
{t.label}
</button>
))}
</div>
{TRACKS.map((t, i) => (
<div key={t.label} className="tabs-cassette__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 initTabsCassette(el) on the mounted element instead.
<div class="tabs-cassette">
<svg class="tabs-cassette__deck" viewBox="0 0 220 120" aria-hidden="true">
<defs>
<clipPath id="tabs-cassette-clip">
<rect x="48" y="52" width="124" height="40" rx="6"></rect>
</clipPath>
</defs>
<rect class="tabs-cassette__body" x="4" y="4" width="212" height="112" rx="8"></rect>
<rect class="tabs-cassette__label" x="16" y="12" width="188" height="32" rx="3"></rect>
<text class="tabs-cassette__side" x="24" y="24">SIDE A</text>
<text class="tabs-cassette__title" x="24" y="37">Intro</text>
<rect class="tabs-cassette__window" x="48" y="52" width="124" height="40" rx="6"></rect>
<path class="tabs-cassette__tape" d="M78 101.24L36 106H184L142 83.76"></path>
<g>
<circle class="tabs-cassette__pack" cx="78" cy="72" r="29.24" clip-path="url(#tabs-cassette-clip)"></circle>
<circle class="tabs-cassette__hub" cx="78" cy="72" r="9"></circle>
<g transform="rotate(0 78 72)">
<line class="tabs-cassette__spoke" x1="78" y1="72" x2="78" y2="66" transform="rotate(0 78 72)"></line>
<line class="tabs-cassette__spoke" x1="78" y1="72" x2="78" y2="66" transform="rotate(120 78 72)"></line>
<line class="tabs-cassette__spoke" x1="78" y1="72" x2="78" y2="66" transform="rotate(240 78 72)"></line>
</g>
</g>
<g>
<circle class="tabs-cassette__pack" cx="142" cy="72" r="11.76" clip-path="url(#tabs-cassette-clip)"></circle>
<circle class="tabs-cassette__hub" cx="142" cy="72" r="9"></circle>
<g transform="rotate(0 142 72)">
<line class="tabs-cassette__spoke" x1="142" y1="72" x2="142" y2="66" transform="rotate(0 142 72)"></line>
<line class="tabs-cassette__spoke" x1="142" y1="72" x2="142" y2="66" transform="rotate(120 142 72)"></line>
<line class="tabs-cassette__spoke" x1="142" y1="72" x2="142" y2="66" transform="rotate(240 142 72)"></line>
</g>
</g>
<path class="tabs-cassette__foot" d="M44 116L54 104H166L176 116Z"></path>
</svg>
<p class="tabs-cassette__read" aria-hidden="true">
<b>Playing</b>
<span>024</span>
</p>
<div class="tabs-cassette__list" role="tablist" aria-label="Side A">
<button class="tabs-cassette__tab" type="button" role="tab" id="tabs-cassette-tab-0" aria-selected="true" aria-controls="tabs-cassette-panel-0" tabindex="0">Intro</button>
<button class="tabs-cassette__tab" type="button" role="tab" id="tabs-cassette-tab-1" aria-selected="false" aria-controls="tabs-cassette-panel-1" tabindex="-1">Tides</button>
<button class="tabs-cassette__tab" type="button" role="tab" id="tabs-cassette-tab-2" aria-selected="false" aria-controls="tabs-cassette-panel-2" tabindex="-1">Night</button>
<button class="tabs-cassette__tab" type="button" role="tab" id="tabs-cassette-tab-3" aria-selected="false" aria-controls="tabs-cassette-panel-3" tabindex="-1">Outro</button>
</div>
<div class="tabs-cassette__panel" role="tabpanel" id="tabs-cassette-panel-0" aria-labelledby="tabs-cassette-tab-0">Tape hiss, a count-in and the first chord. Forty seconds long.</div>
<div class="tabs-cassette__panel" role="tabpanel" id="tabs-cassette-panel-1" aria-labelledby="tabs-cassette-tab-1" hidden="">The single. Slow drums under a guitar that never quite resolves.</div>
<div class="tabs-cassette__panel" role="tabpanel" id="tabs-cassette-panel-2" aria-labelledby="tabs-cassette-tab-2" hidden="">Recorded live in one take, with the rain on the studio roof.</div>
<div class="tabs-cassette__panel" role="tabpanel" id="tabs-cassette-panel-3" aria-labelledby="tabs-cassette-tab-3" hidden="">The intro played backwards, fading into the run-out.</div>
</div>.tabs-cassette { width: 240px; }
.tabs-cassette__deck { display: block; width: 220px; height: 120px; margin: 0 auto; }
.tabs-cassette__body { fill: light-dark(#dbdde1, #2e3035); }
.tabs-cassette__label { fill: light-dark(#42454c, #e5e6e9); }
.tabs-cassette__title { fill: light-dark(#f3f4f7, #1a1c1f); font-size: 11px; font-weight: 700; }
.tabs-cassette__side { fill: light-dark(#cbced3, #393c41); font-size: 8px; font-weight: 700; letter-spacing: 0.08em; }
.tabs-cassette__window { fill: light-dark(#fbfcfd, #121417); }
.tabs-cassette__pack { fill: light-dark(#cbced3, #393c41); }
.tabs-cassette__hub { fill: light-dark(#42454c, #e5e6e9); }
.tabs-cassette__spoke { stroke: light-dark(#dbdde1, #2e3035); stroke-width: 2.5; stroke-linecap: round; }
.tabs-cassette__tape { fill: none; stroke: light-dark(#6d7179, #7d8087); stroke-width: 1.5; }
.tabs-cassette__foot { fill: light-dark(#cbced3, #393c41); }
.tabs-cassette__read { display: flex; justify-content: space-between; margin: 6px 10px 8px; font-size: 11px; color: light-dark(#5f626a, #a7abb2); font-variant-numeric: tabular-nums; }
.tabs-cassette__read b { color: light-dark(#373a40, #f1f2f3); font-weight: 600; }
.tabs-cassette__list { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; }
.tabs-cassette__tab { height: 28px; border: 0; border-radius: 7px; background: light-dark(#ebedf0, #232529); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 500; cursor: pointer; transition: background-color 0.2s, color 0.2s; }
.tabs-cassette__tab[aria-selected='true'] { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-cassette__tab:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
.tabs-cassette__panel { padding: 10px 2px 0; min-height: calc(3.1em + 10px); color: light-dark(#50535b, #c6c8cd); font-size: 13px; line-height: 1.55; }function initTabsCassette(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'; });
// Where each track starts along the tape, from 0 (all on the left reel) to 1.
var AT = [0.04, 0.33, 0.62, 0.9];
var TAPE = 900;
var L = { x: 78, y: 72 };
var R = { x: 142, y: 72 };
var packs = root.querySelectorAll('.tabs-cassette__pack');
var spokes = root.querySelectorAll('.tabs-cassette__hub + g');
var tape = root.querySelector('.tabs-cassette__tape');
var title = root.querySelector('.tabs-cassette__title');
var mode = root.querySelector('.tabs-cassette__read b');
var counter = root.querySelector('.tabs-cassette__read span');
var s = { p: AT[sel], aL: 0, aR: 0 };
var raf = 0;
function packL(p) { return 11 + 19 * (1 - p); }
function packR(p) { return 11 + 19 * p; }
function draw(label) {
var rl = packL(s.p);
var rr = packR(s.p);
packs[0].setAttribute('r', rl);
packs[1].setAttribute('r', rr);
spokes[0].setAttribute('transform', 'rotate(' + s.aL + ' ' + L.x + ' ' + L.y + ')');
spokes[1].setAttribute('transform', 'rotate(' + s.aR + ' ' + R.x + ' ' + R.y + ')');
tape.setAttribute('d', 'M' + L.x + ' ' + (L.y + rl) + 'L36 106H184L' + R.x + ' ' + (R.y + rr));
mode.textContent = label;
counter.textContent = String(Math.round(s.p * 600)).padStart(3, '0');
}
// Winding to a track runs the reels fast, forward or back. Each reel turns at the speed the tape
// leaves or reaches it, so the nearly empty one spins hardest; the counter follows the tape.
function wind() {
cancelAnimationFrame(raf);
var from = s.p;
var to = AT[sel];
if (from === to) return;
var label = to > from ? 'Fast forward' : 'Rewind';
if (matchMedia('(prefers-reduced-motion: reduce)').matches) {
s.p = to;
return draw('Playing');
}
var dur = 350 + 1300 * Math.abs(to - from);
var t0 = performance.now();
function step(now) {
var k = Math.min(1, (now - t0) / dur);
var e = k < 0.5 ? 4 * k * k * k : 1 - Math.pow(-2 * k + 2, 3) / 2;
var p = from + (to - from) * e;
var dp = p - s.p;
s.aL += ((dp * TAPE) / packL(p)) * (180 / Math.PI);
s.aR += ((dp * TAPE) / packR(p)) * (180 / Math.PI);
s.p = p;
draw(k < 1 ? label : 'Playing');
if (k < 1) raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
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; });
title.textContent = tabs[i].textContent;
wind();
}
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 () {
cancelAnimationFrame(raf);
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-cassette').forEach(initTabsCassette);Goal: Create a "Cassette" tab set.
Visual behavior: Tracks on a tape: pick one and the reels wind fast forward or back to it, the near-empty reel spinning hardest, the tape packs trading size and the counter running.
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 track has a position along the tape from 0 to 1. Winding eases that position from the old track to the new one over a time that grows with the distance.
- The two tape packs are circles whose radii trade with the position, and each reel turns by the tape that moved divided by its own pack radius, so the near-empty reel spins fastest.
- A readout shows Fast forward or Rewind while it winds and a counter that follows the tape.
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 tape jumps to the track.
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 "Cassette" tab set.
Visual behavior: Tracks on a tape: pick one and the reels wind fast forward or back to it, the near-empty reel spinning hardest, the tape packs trading size and the counter running.
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 track has a position along the tape from 0 to 1. Winding eases that position from the old track to the new one over a time that grows with the distance.
- The two tape packs are circles whose radii trade with the position, and each reel turns by the tape that moved divided by its own pack radius, so the near-empty reel spins fastest.
- A readout shows Fast forward or Rewind while it winds and a counter that follows the tape.
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 tape jumps to the track.
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
Tracks on a tape: pick one and the reels wind fast forward or back to it, the near-empty reel spinning hardest, the tape packs trading size and the counter running.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change
- Techniques
- raf, 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 tape jumps to the track.
More tabs
See allEvening news
Floods in the valley, and the bridge reopens Monday.
Match of the day
Late winner at the harbour ground, 2 to 1.
Late film
A silent-era heist, starting in ten minutes.