Jukebox
import { useId, useRef, useState } from 'react';
const SONGS = [
{ code: 'A1', label: 'Night Drive', artist: 'The Lumens', c: 'light-dark(#42454c, #e5e6e9)' },
{ code: 'B4', label: 'Paper Moon', artist: 'Ida Grey', c: 'light-dark(#5f626a, #a7abb2)' },
{ code: 'C2', label: 'Slow Train', artist: 'Marlow & Co', c: 'light-dark(#cbced3, #393c41)' },
];
const css = `
@keyframes tabs-jukebox-spin { to { transform: rotate(360deg); } }
@keyframes tabs-jukebox-in { from { transform: translateX(130%) rotate(90deg); } }
@keyframes tabs-jukebox-out { to { transform: translateX(-130%) rotate(-90deg); } }
@keyframes tabs-jukebox-arm-a { 0% { transform: rotate(26deg); } 25%, 70% { transform: rotate(-8deg); } 100% { transform: rotate(26deg); } }
@keyframes tabs-jukebox-arm-b { 0% { transform: rotate(26deg); } 25%, 70% { transform: rotate(-8deg); } 100% { transform: rotate(26deg); } }
@keyframes tabs-jukebox-eq { from { transform: scaleY(0.3); } to { transform: scaleY(1); } }
.tabs-jukebox { display: grid; grid-template-columns: 116px 1fr; gap: 12px 14px; width: 270px; }
.tabs-jukebox__deck { position: relative; width: 116px; height: 116px; overflow: hidden; border-radius: 14px; background: linear-gradient(light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 22px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
.tabs-jukebox__record { position: absolute; left: 9px; top: 11px; width: 94px; height: 94px; }
.tabs-jukebox[data-armed] .tabs-jukebox__record[data-on] { animation: tabs-jukebox-in 0.55s cubic-bezier(0.3, 1.1, 0.5, 1) 0.2s backwards; }
.tabs-jukebox__record[data-prev] { animation: tabs-jukebox-out 0.35s ease-in forwards; }
.tabs-jukebox__disc {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background:
radial-gradient(circle, light-dark(#fbfcfd, #121417) 0 2px, var(--tabs-jukebox-c) 2.5px 16px, light-dark(#fbfcfd, #121417) 16.5px 18px, transparent 18.5px),
repeating-radial-gradient(circle, light-dark(#fbfcfd, #121417) 0 1.5px, light-dark(#ebedf0, #232529) 1.5px 3px);
box-shadow: 0 4px 10px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
animation: tabs-jukebox-spin 1.8s linear infinite;
}
.tabs-jukebox__disc::after { content: ''; position: absolute; left: 50%; top: 32px; width: 2px; height: 8px; margin-left: -1px; border-radius: 1px; background: light-dark(#fbfcfd, #121417); }
.tabs-jukebox__arm { position: absolute; right: 12px; top: 6px; width: 4px; height: 70px; border-radius: 2px; background: light-dark(#50535b, #c6c8cd); transform: rotate(26deg); transform-origin: 50% 4px; }
.tabs-jukebox__arm::before { content: ''; position: absolute; left: -4px; top: -2px; width: 12px; height: 12px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); }
.tabs-jukebox__arm::after { content: ''; position: absolute; left: -2px; bottom: -4px; width: 8px; height: 12px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); }
.tabs-jukebox[data-flip='a'] .tabs-jukebox__arm { animation: tabs-jukebox-arm-a 1s ease-in-out; }
.tabs-jukebox[data-flip='b'] .tabs-jukebox__arm { animation: tabs-jukebox-arm-b 1s ease-in-out; }
.tabs-jukebox__list { display: grid; align-content: center; gap: 6px; }
.tabs-jukebox__tab { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; border: 0; border-radius: 8px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), 0 2px 0 light-dark(#fbfcfd, #121417); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 600; text-align: left; cursor: pointer; transition: transform 0.1s, box-shadow 0.1s, color 0.2s; }
.tabs-jukebox__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-jukebox__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); transform: translateY(2px); box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), 0 0 0 light-dark(#fbfcfd, #121417); }
.tabs-jukebox__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-jukebox__code { padding: 1px 4px; border-radius: 3px; background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px; }
.tabs-jukebox__tab[aria-selected='true'] .tabs-jukebox__code { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-jukebox__panel { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; }
.tabs-jukebox__eq { display: flex; align-items: flex-end; gap: 2px; height: 16px; }
.tabs-jukebox__eq span { width: 3px; height: 100%; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); transform-origin: 50% 100%; animation: tabs-jukebox-eq 0.4s ease-in-out infinite alternate; }
.tabs-jukebox__song { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.tabs-jukebox__artist { color: light-dark(#5f626a, #a7abb2); font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
.tabs-jukebox * { animation: none !important; }
.tabs-jukebox__record[data-prev] { display: none; }
}
`;
export function JukeboxTabs() {
const id = useId();
const [state, setState] = useState({ sel: 0, prev: -1, flip: 0 });
const tabs = useRef<(HTMLButtonElement | null)[]>([]);
const { sel, prev, flip } = state;
const select = (i: number) => {
if (i !== sel) setState((s) => ({ sel: i, prev: s.sel, flip: s.flip + 1 }));
};
const onKeyDown = (e: React.KeyboardEvent) => {
const n = SONGS.length;
const next = { ArrowDown: (sel + 1) % n, ArrowUp: (sel + n - 1) % n, Home: 0, End: n - 1 }[e.key];
if (next === undefined) return;
e.preventDefault();
select(next);
tabs.current[next]?.focus();
};
return (
<>
<style>
{css}
</style>
<div className="tabs-jukebox" data-armed={flip ? '' : undefined} data-flip={flip ? (flip % 2 ? 'a' : 'b') : undefined}>
<span className="tabs-jukebox__deck" aria-hidden="true">
{SONGS.map((s, i) =>
i === sel || i === prev ? (
<span
key={s.code}
className="tabs-jukebox__record"
data-on={i === sel ? '' : undefined}
data-prev={i === prev ? '' : undefined}
style={{ '--tabs-jukebox-c': s.c } as React.CSSProperties}
>
<span className="tabs-jukebox__disc" />
</span>
) : null,
)}
<span className="tabs-jukebox__arm" />
</span>
<div className="tabs-jukebox__list" role="tablist" aria-label="Songs" aria-orientation="vertical" onKeyDown={onKeyDown}>
{SONGS.map((s, i) => (
<button
key={s.code}
ref={(el) => {
tabs.current[i] = el;
}}
className="tabs-jukebox__tab"
type="button"
role="tab"
id={`${id}-tab-${i}`}
aria-selected={sel === i}
aria-controls={`${id}-panel-${i}`}
tabIndex={sel === i ? 0 : -1}
onClick={() => select(i)}
>
<span className="tabs-jukebox__code">{s.code}</span>
{s.label}
</button>
))}
</div>
{SONGS.map((s, i) => (
<div key={s.code} className="tabs-jukebox__panel" role="tabpanel" id={`${id}-panel-${i}`} aria-labelledby={`${id}-tab-${i}`} hidden={sel !== i}>
<span className="tabs-jukebox__eq" aria-hidden="true">
<span />
<span style={{ animationDelay: '-0.15s' }} />
<span style={{ animationDelay: '-0.3s' }} />
</span>
<span>
<span className="tabs-jukebox__song">{s.label}</span> <span className="tabs-jukebox__artist">by {s.artist}</span>
</span>
</div>
))}
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initTabsJukebox(el) on the mounted element instead.
<div class="tabs-jukebox">
<span class="tabs-jukebox__deck" aria-hidden="true">
<span class="tabs-jukebox__record" data-on="" style="--tabs-jukebox-c:light-dark(#42454c, #e5e6e9)">
<span class="tabs-jukebox__disc"></span>
</span>
<span class="tabs-jukebox__arm"></span>
</span>
<div class="tabs-jukebox__list" role="tablist" aria-label="Songs" aria-orientation="vertical">
<button class="tabs-jukebox__tab" type="button" role="tab" id="tabs-jukebox-tab-0" aria-selected="true" aria-controls="tabs-jukebox-panel-0" tabindex="0"><span class="tabs-jukebox__code">A1</span>Night Drive</button>
<button class="tabs-jukebox__tab" type="button" role="tab" id="tabs-jukebox-tab-1" aria-selected="false" aria-controls="tabs-jukebox-panel-1" tabindex="-1"><span class="tabs-jukebox__code">B4</span>Paper Moon</button>
<button class="tabs-jukebox__tab" type="button" role="tab" id="tabs-jukebox-tab-2" aria-selected="false" aria-controls="tabs-jukebox-panel-2" tabindex="-1"><span class="tabs-jukebox__code">C2</span>Slow Train</button>
</div>
<div class="tabs-jukebox__panel" role="tabpanel" id="tabs-jukebox-panel-0" aria-labelledby="tabs-jukebox-tab-0">
<span class="tabs-jukebox__eq" aria-hidden="true">
<span></span>
<span style="animation-delay:-0.15s"></span>
<span style="animation-delay:-0.3s"></span>
</span>
<span>
<span class="tabs-jukebox__song">Night Drive</span>
<span class="tabs-jukebox__artist">by The Lumens</span>
</span>
</div>
<div class="tabs-jukebox__panel" role="tabpanel" id="tabs-jukebox-panel-1" aria-labelledby="tabs-jukebox-tab-1" hidden="">
<span class="tabs-jukebox__eq" aria-hidden="true">
<span></span>
<span style="animation-delay:-0.15s"></span>
<span style="animation-delay:-0.3s"></span>
</span>
<span>
<span class="tabs-jukebox__song">Paper Moon</span>
<span class="tabs-jukebox__artist">by Ida Grey</span>
</span>
</div>
<div class="tabs-jukebox__panel" role="tabpanel" id="tabs-jukebox-panel-2" aria-labelledby="tabs-jukebox-tab-2" hidden="">
<span class="tabs-jukebox__eq" aria-hidden="true">
<span></span>
<span style="animation-delay:-0.15s"></span>
<span style="animation-delay:-0.3s"></span>
</span>
<span>
<span class="tabs-jukebox__song">Slow Train</span>
<span class="tabs-jukebox__artist">by Marlow & Co</span>
</span>
</div>
</div>@keyframes tabs-jukebox-spin { to { transform: rotate(360deg); } }
@keyframes tabs-jukebox-in { from { transform: translateX(130%) rotate(90deg); } }
@keyframes tabs-jukebox-out { to { transform: translateX(-130%) rotate(-90deg); } }
@keyframes tabs-jukebox-arm-a { 0% { transform: rotate(26deg); } 25%, 70% { transform: rotate(-8deg); } 100% { transform: rotate(26deg); } }
@keyframes tabs-jukebox-arm-b { 0% { transform: rotate(26deg); } 25%, 70% { transform: rotate(-8deg); } 100% { transform: rotate(26deg); } }
@keyframes tabs-jukebox-eq { from { transform: scaleY(0.3); } to { transform: scaleY(1); } }
.tabs-jukebox { display: grid; grid-template-columns: 116px 1fr; gap: 12px 14px; width: 270px; }
.tabs-jukebox__deck { position: relative; width: 116px; height: 116px; overflow: hidden; border-radius: 14px; background: linear-gradient(light-dark(#ebedf0, #232529), light-dark(#f3f4f7, #1a1c1f)); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035), 0 12px 22px -12px light-dark(rgba(0, 0, 0, 0.21), rgba(0, 0, 0, 0.7)); }
.tabs-jukebox__record { position: absolute; left: 9px; top: 11px; width: 94px; height: 94px; }
.tabs-jukebox[data-armed] .tabs-jukebox__record[data-on] { animation: tabs-jukebox-in 0.55s cubic-bezier(0.3, 1.1, 0.5, 1) 0.2s backwards; }
.tabs-jukebox__record[data-prev] { animation: tabs-jukebox-out 0.35s ease-in forwards; }
.tabs-jukebox__disc {
display: block;
width: 100%;
height: 100%;
border-radius: 50%;
background:
radial-gradient(circle, light-dark(#fbfcfd, #121417) 0 2px, var(--tabs-jukebox-c) 2.5px 16px, light-dark(#fbfcfd, #121417) 16.5px 18px, transparent 18.5px),
repeating-radial-gradient(circle, light-dark(#fbfcfd, #121417) 0 1.5px, light-dark(#ebedf0, #232529) 1.5px 3px);
box-shadow: 0 4px 10px light-dark(rgba(0, 0, 0, 0.15), rgba(0, 0, 0, 0.5));
animation: tabs-jukebox-spin 1.8s linear infinite;
}
.tabs-jukebox__disc::after { content: ''; position: absolute; left: 50%; top: 32px; width: 2px; height: 8px; margin-left: -1px; border-radius: 1px; background: light-dark(#fbfcfd, #121417); }
.tabs-jukebox__arm { position: absolute; right: 12px; top: 6px; width: 4px; height: 70px; border-radius: 2px; background: light-dark(#50535b, #c6c8cd); transform: rotate(26deg); transform-origin: 50% 4px; }
.tabs-jukebox__arm::before { content: ''; position: absolute; left: -4px; top: -2px; width: 12px; height: 12px; border-radius: 50%; background: light-dark(#6d7179, #7d8087); }
.tabs-jukebox__arm::after { content: ''; position: absolute; left: -2px; bottom: -4px; width: 8px; height: 12px; border-radius: 2px; background: light-dark(#42454c, #e5e6e9); }
.tabs-jukebox[data-flip='a'] .tabs-jukebox__arm { animation: tabs-jukebox-arm-a 1s ease-in-out; }
.tabs-jukebox[data-flip='b'] .tabs-jukebox__arm { animation: tabs-jukebox-arm-b 1s ease-in-out; }
.tabs-jukebox__list { display: grid; align-content: center; gap: 6px; }
.tabs-jukebox__tab { display: flex; align-items: center; gap: 8px; height: 30px; padding: 0 8px; border: 0; border-radius: 8px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#ebedf0, #232529), 0 2px 0 light-dark(#fbfcfd, #121417); color: light-dark(#50535b, #c6c8cd); font: inherit; font-size: 12px; font-weight: 600; text-align: left; cursor: pointer; transition: transform 0.1s, box-shadow 0.1s, color 0.2s; }
.tabs-jukebox__tab:hover { color: light-dark(#373a40, #f1f2f3); }
.tabs-jukebox__tab[aria-selected='true'] { color: light-dark(#373a40, #f1f2f3); transform: translateY(2px); box-shadow: inset 0 0 0 1px light-dark(#6d7179, #7d8087), 0 0 0 light-dark(#fbfcfd, #121417); }
.tabs-jukebox__tab:focus-visible { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 2px; }
.tabs-jukebox__code { padding: 1px 4px; border-radius: 3px; background: light-dark(#dbdde1, #2e3035); color: light-dark(#373a40, #f1f2f3); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 9.5px; }
.tabs-jukebox__tab[aria-selected='true'] .tabs-jukebox__code { background: light-dark(#373a40, #f1f2f3); color: light-dark(#fbfcfd, #121417); }
.tabs-jukebox__panel { grid-column: 1 / -1; display: flex; align-items: center; gap: 10px; }
.tabs-jukebox__eq { display: flex; align-items: flex-end; gap: 2px; height: 16px; }
.tabs-jukebox__eq span { width: 3px; height: 100%; border-radius: 1px; background: light-dark(#50535b, #c6c8cd); transform-origin: 50% 100%; animation: tabs-jukebox-eq 0.4s ease-in-out infinite alternate; }
.tabs-jukebox__song { color: light-dark(#373a40, #f1f2f3); font-size: 13px; font-weight: 600; }
.tabs-jukebox__artist { color: light-dark(#5f626a, #a7abb2); font-size: 12px; }
@media (prefers-reduced-motion: reduce) {
.tabs-jukebox * { animation: none !important; }
.tabs-jukebox__record[data-prev] { display: none; }
}function initTabsJukebox(root) {
var list = root.querySelector('[role="tablist"]');
var tabs = Array.prototype.slice.call(list.querySelectorAll('[role="tab"]'));
var panels = root.querySelectorAll('[role="tabpanel"]');
var sel = tabs.findIndex(function (tab) { return tab.getAttribute('aria-selected') === 'true'; });
var COLOURS = ['light-dark(#42454c, #e5e6e9)', 'light-dark(#5f626a, #a7abb2)', 'light-dark(#cbced3, #393c41)'];
var flip = 0;
var deck = root.querySelector('.tabs-jukebox__deck');
var arm = root.querySelector('.tabs-jukebox__arm');
// Only the playing record and the one leaving are on the deck.
var records = {};
records[sel] = deck.querySelector('.tabs-jukebox__record');
function record(k) {
if (records[k]) return records[k];
var el = document.createElement('span');
el.className = 'tabs-jukebox__record';
el.style.setProperty('--tabs-jukebox-c', COLOURS[k]);
var disc = document.createElement('span');
disc.className = 'tabs-jukebox__disc';
el.appendChild(disc);
// Keep the records in song order, under the arm.
var after = arm;
for (var j = COLOURS.length - 1; j > k; j--) if (records[j]) after = records[j];
deck.insertBefore(el, after);
records[k] = el;
return el;
}
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; });
Object.keys(records).forEach(function (k) {
if (+k !== i && +k !== prev) {
records[k].remove();
delete records[k];
}
});
record(prev).removeAttribute('data-on');
record(prev).setAttribute('data-prev', '');
record(i).removeAttribute('data-prev');
record(i).setAttribute('data-on', '');
// Each change flips data-flip between a and b, so the keyframes restart every time.
flip += 1;
root.setAttribute('data-flip', flip % 2 ? 'a' : 'b');
root.setAttribute('data-armed', '');
}
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 = { ArrowDown: (sel + 1) % n, ArrowUp: (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 () {
list.removeEventListener('click', onClick);
list.removeEventListener('keydown', onKeyDown);
};
}
document.querySelectorAll('.tabs-jukebox').forEach(initTabsJukebox);Goal: Create a "Jukebox" tab set.
Visual behavior: A jukebox song picker: the old record slides off the deck, the new one slides on and spins, the tonearm lifts and drops back, and an equaliser plays beside the title.
Technique:
- The ARIA tabs pattern, vertical: a role="tablist" with aria-orientation="vertical", so Up and Down (plus Home and End) move between tabs and select as they go; buttons carry role="tab", aria-selected and aria-controls, and only the selected one is in the Tab order.
- Only two records are on the deck at a time: the playing one (data-on) slides in and spins, the one you left (data-prev) slides out. Each is a radial-gradient disc in its own shade.
- The tonearm lifts and drops on keyframes. Effects that should replay on every change alternate between two identically written keyframes, chosen by a data-flip attribute that flips between a and b on each change, so the browser restarts them.
- A data-armed attribute is set on the first change, so nothing plays on page load.
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 records swap without sliding or spinning and the arm and equaliser are still.
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 "Jukebox" tab set.
Visual behavior: A jukebox song picker: the old record slides off the deck, the new one slides on and spins, the tonearm lifts and drops back, and an equaliser plays beside the title.
Technique:
- The ARIA tabs pattern, vertical: a role="tablist" with aria-orientation="vertical", so Up and Down (plus Home and End) move between tabs and select as they go; buttons carry role="tab", aria-selected and aria-controls, and only the selected one is in the Tab order.
- Only two records are on the deck at a time: the playing one (data-on) slides in and spins, the one you left (data-prev) slides out. Each is a radial-gradient disc in its own shade.
- The tonearm lifts and drops on keyframes. Effects that should replay on every change alternate between two identically written keyframes, chosen by a data-flip attribute that flips between a and b on each change, so the browser restarts them.
- A data-armed attribute is set on the first change, so nothing plays on page load.
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 records swap without sliding or spinning and the arm and equaliser are still.
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 jukebox song picker: the old record slides off the deck, the new one slides on and spins, the tonearm lifts and drops back, and an equaliser plays beside the title.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- click, state change, loop
- Techniques
- keyframes, radial gradient, state machine
- 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 records swap without sliding or spinning and the arm and equaliser are still.