Turntable
import { useEffect, useRef } from 'react';
const css = `
.toggle-turntable { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.toggle-turntable__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-turntable__deck { display: block; width: 120px; height: 83px; border-radius: 9px; overflow: visible; }
.toggle-turntable__input:focus-visible + .toggle-turntable__deck { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.toggle-turntable__plinth { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#dbdde1, #2e3035); }
.toggle-turntable__mat { fill: light-dark(#dbdde1, #2e3035); }
.toggle-turntable__vinyl { fill: light-dark(#fbfcfd, #121417); }
.toggle-turntable__groove { fill: none; stroke: light-dark(#ebedf0, #232529); stroke-width: 0.7; }
.toggle-turntable__label { fill: light-dark(#50535b, #c6c8cd); }
.toggle-turntable__mark { fill: light-dark(#cbced3, #393c41); }
.toggle-turntable__sheen { fill: none; stroke: color-mix(in srgb, light-dark(#373a40, #f1f2f3) 14%, transparent); stroke-width: 3; stroke-linecap: round; }
.toggle-turntable__pivot { fill: light-dark(#cbced3, #393c41); }
.toggle-turntable__hub { fill: light-dark(#5f626a, #a7abb2); }
.toggle-turntable__rest { fill: light-dark(#dbdde1, #2e3035); }
.toggle-turntable__led { fill: light-dark(#cbced3, #393c41); transition: fill 0.3s; }
.toggle-turntable__input:checked + .toggle-turntable__deck .toggle-turntable__led { fill: light-dark(#373a40, #f1f2f3); }
.toggle-turntable__tube { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.toggle-turntable__head { fill: light-dark(#42454c, #e5e6e9); }
.toggle-turntable__weight { fill: light-dark(#6d7179, #7d8087); }
/* The arm swings home at once; going on it waits for the platter to get up to speed. */
.toggle-turntable__arm { transform-box: view-box; transform-origin: 88px 12px; transition: transform 0.8s cubic-bezier(0.45, 0, 0.3, 1); }
.toggle-turntable__input:checked + .toggle-turntable__deck .toggle-turntable__arm { transform: rotate(34deg); transition-delay: 0.45s; }
@media (prefers-reduced-motion: reduce) {
.toggle-turntable__arm, .toggle-turntable__input:checked + .toggle-turntable__deck .toggle-turntable__arm { transition: none; }
}
`;
const GROOVES = [24.5, 22, 19.5, 17, 14.5, 12];
export function TurntableToggle() {
const record = useRef<SVGGElement>(null);
const st = useRef({ on: false, a: 0, v: 0, raf: 0 });
useEffect(() => () => cancelAnimationFrame(st.current.raf), []);
// The platter runs at 33 rpm (200 degrees a second). It takes about a second to get up to speed
// and longer to coast down, so the label keeps turning for a moment after the arm has lifted.
const spin = (on: boolean) => {
const s = st.current;
s.on = on;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
cancelAnimationFrame(s.raf);
let last = performance.now();
const step = (now: number) => {
const dt = Math.min(0.05, (now - last) / 1000);
last = now;
const goal = s.on ? 200 : 0;
s.v += (goal - s.v) * Math.min(1, dt * (s.on ? 2.6 : 1.5));
s.a = (s.a + s.v * dt) % 360;
record.current!.setAttribute('transform', `rotate(${s.a.toFixed(2)} 40 36)`);
if (!s.on && s.v < 0.5) return;
s.raf = requestAnimationFrame(step);
};
s.raf = requestAnimationFrame(step);
};
return (
<>
<style>
{css}
</style>
<label className="toggle-turntable">
<input className="toggle-turntable__input" type="checkbox" role="switch" onChange={(e) => spin(e.target.checked)} />
<svg className="toggle-turntable__deck" viewBox="0 0 104 72" aria-hidden="true">
<rect className="toggle-turntable__plinth" x="1.5" y="1.5" width="101" height="69" rx="8" />
<circle className="toggle-turntable__mat" cx="40" cy="36" r="29" />
<g ref={record}>
<circle className="toggle-turntable__vinyl" cx="40" cy="36" r="26.5" />
{GROOVES.map((r) => (
<circle key={r} className="toggle-turntable__groove" cx="40" cy="36" r={r} />
))}
<circle className="toggle-turntable__label" cx="40" cy="36" r="8" />
<rect className="toggle-turntable__mark" x="38.5" y="29.5" width="3" height="4" rx="1" />
</g>
{/* Light catching the vinyl stays put while the record turns under it. */}
<path className="toggle-turntable__sheen" d="M22 22A24 24 0 0 1 32 15" />
<path className="toggle-turntable__sheen" d="M58 50A24 24 0 0 1 48 57" />
<circle className="toggle-turntable__hub" cx="40" cy="36" r="1.5" />
<circle className="toggle-turntable__rest" cx="87" cy="57" r="3" />
<circle className="toggle-turntable__led" cx="10" cy="62" r="2" />
<circle className="toggle-turntable__pivot" cx="88" cy="12" r="6" />
<g className="toggle-turntable__arm">
<rect className="toggle-turntable__weight" x="84.5" y="1" width="7" height="6" rx="1.5" />
<path className="toggle-turntable__tube" d="M88 12V46L85.5 53" />
<rect className="toggle-turntable__head" x="82" y="51" width="6" height="8" rx="1.5" transform="rotate(20 85 55)" />
</g>
<circle className="toggle-turntable__hub" cx="88" cy="12" r="2.5" />
</svg>
<span>Music</span>
</label>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initToggleTurntable(el) on the mounted element instead.
<label class="toggle-turntable">
<input class="toggle-turntable__input" type="checkbox" role="switch"/>
<svg class="toggle-turntable__deck" viewBox="0 0 104 72" aria-hidden="true">
<rect class="toggle-turntable__plinth" x="1.5" y="1.5" width="101" height="69" rx="8"></rect>
<circle class="toggle-turntable__mat" cx="40" cy="36" r="29"></circle>
<g>
<circle class="toggle-turntable__vinyl" cx="40" cy="36" r="26.5"></circle>
<circle class="toggle-turntable__groove" cx="40" cy="36" r="24.5"></circle>
<circle class="toggle-turntable__groove" cx="40" cy="36" r="22"></circle>
<circle class="toggle-turntable__groove" cx="40" cy="36" r="19.5"></circle>
<circle class="toggle-turntable__groove" cx="40" cy="36" r="17"></circle>
<circle class="toggle-turntable__groove" cx="40" cy="36" r="14.5"></circle>
<circle class="toggle-turntable__groove" cx="40" cy="36" r="12"></circle>
<circle class="toggle-turntable__label" cx="40" cy="36" r="8"></circle>
<rect class="toggle-turntable__mark" x="38.5" y="29.5" width="3" height="4" rx="1"></rect>
</g>
<path class="toggle-turntable__sheen" d="M22 22A24 24 0 0 1 32 15"></path>
<path class="toggle-turntable__sheen" d="M58 50A24 24 0 0 1 48 57"></path>
<circle class="toggle-turntable__hub" cx="40" cy="36" r="1.5"></circle>
<circle class="toggle-turntable__rest" cx="87" cy="57" r="3"></circle>
<circle class="toggle-turntable__led" cx="10" cy="62" r="2"></circle>
<circle class="toggle-turntable__pivot" cx="88" cy="12" r="6"></circle>
<g class="toggle-turntable__arm">
<rect class="toggle-turntable__weight" x="84.5" y="1" width="7" height="6" rx="1.5"></rect>
<path class="toggle-turntable__tube" d="M88 12V46L85.5 53"></path>
<rect class="toggle-turntable__head" x="82" y="51" width="6" height="8" rx="1.5" transform="rotate(20 85 55)"></rect>
</g>
<circle class="toggle-turntable__hub" cx="88" cy="12" r="2.5"></circle>
</svg>
<span>Music</span>
</label>.toggle-turntable { display: inline-flex; align-items: center; gap: 14px; color: light-dark(#42454c, #e5e6e9); font-size: 15px; cursor: pointer; user-select: none; }
.toggle-turntable__input { position: absolute; width: 1px; height: 1px; margin: 0; opacity: 0; }
.toggle-turntable__deck { display: block; width: 120px; height: 83px; border-radius: 9px; overflow: visible; }
.toggle-turntable__input:focus-visible + .toggle-turntable__deck { outline: 2px solid light-dark(#5f626a, #a7abb2); outline-offset: 4px; }
.toggle-turntable__plinth { fill: light-dark(#ebedf0, #232529); stroke: light-dark(#dbdde1, #2e3035); }
.toggle-turntable__mat { fill: light-dark(#dbdde1, #2e3035); }
.toggle-turntable__vinyl { fill: light-dark(#fbfcfd, #121417); }
.toggle-turntable__groove { fill: none; stroke: light-dark(#ebedf0, #232529); stroke-width: 0.7; }
.toggle-turntable__label { fill: light-dark(#50535b, #c6c8cd); }
.toggle-turntable__mark { fill: light-dark(#cbced3, #393c41); }
.toggle-turntable__sheen { fill: none; stroke: color-mix(in srgb, light-dark(#373a40, #f1f2f3) 14%, transparent); stroke-width: 3; stroke-linecap: round; }
.toggle-turntable__pivot { fill: light-dark(#cbced3, #393c41); }
.toggle-turntable__hub { fill: light-dark(#5f626a, #a7abb2); }
.toggle-turntable__rest { fill: light-dark(#dbdde1, #2e3035); }
.toggle-turntable__led { fill: light-dark(#cbced3, #393c41); transition: fill 0.3s; }
.toggle-turntable__input:checked + .toggle-turntable__deck .toggle-turntable__led { fill: light-dark(#373a40, #f1f2f3); }
.toggle-turntable__tube { fill: none; stroke: light-dark(#50535b, #c6c8cd); stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.toggle-turntable__head { fill: light-dark(#42454c, #e5e6e9); }
.toggle-turntable__weight { fill: light-dark(#6d7179, #7d8087); }
/* The arm swings home at once; going on it waits for the platter to get up to speed. */
.toggle-turntable__arm { transform-box: view-box; transform-origin: 88px 12px; transition: transform 0.8s cubic-bezier(0.45, 0, 0.3, 1); }
.toggle-turntable__input:checked + .toggle-turntable__deck .toggle-turntable__arm { transform: rotate(34deg); transition-delay: 0.45s; }
@media (prefers-reduced-motion: reduce) {
.toggle-turntable__arm, .toggle-turntable__input:checked + .toggle-turntable__deck .toggle-turntable__arm { transition: none; }
}function initToggleTurntable(root) {
var input = root.querySelector('.toggle-turntable__input');
var record = root.querySelector('.toggle-turntable__vinyl').parentNode;
var on = false,
angle = 0,
speed = 0,
raf = 0;
// The platter runs at 33 rpm (200 degrees a second). It takes about a second to get up to speed
// and longer to coast down, so the label keeps turning for a moment after the arm has lifted.
function spin() {
on = input.checked;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
cancelAnimationFrame(raf);
var last = performance.now();
function step(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
speed += ((on ? 200 : 0) - speed) * Math.min(1, dt * (on ? 2.6 : 1.5));
angle = (angle + speed * dt) % 360;
record.setAttribute('transform', 'rotate(' + angle.toFixed(2) + ' 40 36)');
if (!on && speed < 0.5) return;
raf = requestAnimationFrame(step);
}
raf = requestAnimationFrame(step);
}
input.addEventListener('change', spin);
return function () {
input.removeEventListener('change', spin);
cancelAnimationFrame(raf);
};
}
document.querySelectorAll('.toggle-turntable').forEach(initToggleTurntable);Goal: Create a "Turntable" toggle switch.
Visual behavior: A music switch drawn as a record player: the platter spins up to 33 rpm, then the tonearm swings over onto the record; off lifts the arm home at once and the record coasts to a stop.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- The record turns in requestAnimationFrame: its speed eases towards 200 degrees a second when on and towards zero when off, a little quicker going up than coming down, so it spins up and coasts.
- The tonearm is a CSS rotation about its pivot, delayed on the way on so it waits for the platter, and the light comes on with the switch.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion the record stays still and the arm moves 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 "Turntable" toggle switch.
Visual behavior: A music switch drawn as a record player: the platter spins up to 33 rpm, then the tonearm swings over onto the record; off lifts the arm home at once and the record coasts to a stop.
Technique:
- A native checkbox with role="switch", visually hidden but focusable, sits inside a <label> before an aria-hidden drawing and the visible text. Every on state is styled from the input's :checked state through the adjacent sibling combinator, so Space and clicks both work.
- The record turns in requestAnimationFrame: its speed eases towards 200 degrees a second when on and towards zero when off, a little quicker going up than coming down, so it spins up and coasts.
- The tonearm is a CSS rotation about its pivot, delayed on the way on so it waits for the platter, and the light comes on with the switch.
Accessibility: A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion the record stays still and the arm moves 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
A music switch drawn as a record player: the platter spins up to 33 rpm, then the tonearm swings over onto the record; off lifts the arm home at once and the record coasts to a stop.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- click, state change
- Techniques
- raf, svg, css transition
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A native checkbox with role="switch" inside a <label>, so Space toggles it and the visible text names it; :focus-visible on the input outlines the drawing, which is aria-hidden. Under prefers-reduced-motion the record stays still and the arm moves at once.