Turntable

No dependencies
Code format
React + Tailwind
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>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/toggle-turntable
Source on GitHub

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.

More toggles

See all
Valve
Wash
Broadcast
Door