Skip to main content

ns-ui

Tonearm Skate

A read-only tracking-quality gauge built from real pivoted-tonearm geometry: a headshell sweeps a 215mm-pivot arm across a spinning record from lead-in to run-out while a small error needle reads the actual geometric tracking-error function, centering exactly at the two Baerwald null radii (66mm, 120mm) and drifting off zero everywhere else.

Usage

registry/core/tonearm-skate/demo.tsx
"use client";

import { TonearmSkate } from "./component";

export default function TonearmSkateDemo() {
  return (
    <main className="flex min-h-screen items-center justify-center bg-background p-6">
      <div className="w-full max-w-sm">
        <p className="mb-4 font-mono text-xs tracking-widest text-ns-muted">
          ns-ui / tonearm-skate
        </p>
        <TonearmSkate />
        <p className="mt-3 font-mono text-[11px] text-ns-muted">
          the arm sweeps the disc over one 48s side, the error needle centers at each
          null and drifts off it everywhere else
        </p>
      </div>
    </main>
  );
}

Component

registry/core/tonearm-skate/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// TonearmSkate — a read-only tracking-quality gauge built from the actual
// geometry of a pivoted phono tonearm, not an invented needle sweep. A 9"
// arm pivoted 215mm from the spindle (LP_MM) with a 225mm effective
// (pivot-to-stylus) length traces an arc whose distance from the spindle is
// r(alpha) = sqrt(Lp^2 + l^2 + 2*Lp*l*cos(alpha)) — the same law-of-cosines
// relationship that puts a real two-null Baerwald alignment's zero-error
// points at 66mm and 120mm for this pivot distance. The arm sweeps that
// radius from a 146mm lead-in down to a 60mm run-out over 48s, then lifts
// and swings back to the lead-in to start the next "side" — unbounded, no
// dead stop. A separate small tracking-error strip reads a continuously
// computed geometric value: 0 exactly at the two null radii, growing to
// roughly +/-2 degrees at the two grooved edges, with a documented (not
// simulated) interior lobe between the nulls so the needle visibly departs
// center and returns rather than sitting dead flat for 63% of the sweep.
// The needle itself never gains weight/opacity from --ns-accent — only
// stroke-width and opacity scale with |error|, foreground throughout.
//
// Disc spins independently of the arm at 1 rev / 4s, continuously, even
// while the arm is lifted between sides, matching a real motor that never
// stops. Everything is a single rAF loop mutating refs directly (no
// per-frame setState) driven off the frame timestamp so a backgrounded tab
// can't drift the arm out of sync with the disc. Pure SVG + Tailwind token
// classes (text-foreground / text-border / text-ns-muted) — no raster
// surface, so no getComputedStyle/MutationObserver token machinery is
// needed; both themes restyle for free via currentColor.
// ---------------------------------------------------------------------------

// --- real geometry, millimetres -------------------------------------------
const LP_MM = 215; // pivot-to-spindle distance, standard 9" arm
const L_EFF_MM = 225; // effective (pivot-to-stylus) arm length
const R_OUTER_MM = 146; // lead-in groove radius
const R_INNER_MM = 60; // run-out groove radius
const NULL_OUTER_MM = 120; // outer Baerwald null
const NULL_INNER_MM = 66; // inner Baerwald null
const NULL_MID_MM = (NULL_OUTER_MM + NULL_INNER_MM) / 2; // 93mm, interior lobe

// error-angle keyframes (radius mm, error deg), outer -> inner. Anchored to
// the real null radii (0 at each) and the documented +/-2deg edge magnitudes;
// the interior lobe at NULL_MID_MM is set to ~35% of the edge magnitude,
// matching a real Baerwald curve's interior maximum, so the needle visibly
// leaves center and returns rather than reading as a flat line between the
// two nulls (63% of the sweep would otherwise show zero motion).
const ERROR_KEYFRAMES: [r: number, deg: number][] = [
  [R_OUTER_MM, 2.0],
  [NULL_OUTER_MM, 0],
  [NULL_MID_MM, -0.7],
  [NULL_INNER_MM, 0],
  [R_INNER_MM, -2.0],
];
const SKATE_LEAN_DEG = 3; // constant inward headshell cant, present throughout

// --- timing -----------------------------------------------------------------
const DISC_PERIOD_MS = 4_000; // 1 rev / 4s, documented decoupling from 33 1/3 RPM
const SWEEP_MS = 48_000; // outer lead-in to inner run-out
const HOLD_MS = 700; // brief rest at run-out before lift
const LIFT_MS = 1_400; // lift + swing back + set down
const SETTLE_MS = 300; // brief rest at lead-in before the next side
const CYCLE_MS = SWEEP_MS + HOLD_MS + LIFT_MS + SETTLE_MS;
const LIFT_PEAK_VB = 10; // vb units the arm rises at the peak of the lift

// --- scene geometry, viewBox units (vb) -------------------------------------
const VB = 300;
const RECORD_R_VB = 96;
const SCALE = RECORD_R_VB / 150; // mm -> vb, record outer radius is 150mm
const SP = { x: 108, y: 150 }; // spindle
const LP_VB = LP_MM * SCALE;
const L_EFF_VB = L_EFF_MM * SCALE;
const PV = { x: SP.x + LP_VB, y: SP.y }; // pivot, off-platter to the right
const CW_LEN_VB = 34; // counterweight distance behind the pivot
const THREAD_ANCHOR = { x: PV.x - 12, y: PV.y - 14 }; // anti-skate post

function toRad(deg: number) {
  return (deg * Math.PI) / 180;
}
function toDeg(rad: number) {
  return (rad * 180) / Math.PI;
}
function clamp(n: number, lo: number, hi: number) {
  return Math.max(lo, Math.min(hi, n));
}
function lerp(a: number, b: number, t: number) {
  return a + (b - a) * t;
}
function smoothstep(t: number) {
  const c = clamp(t, 0, 1);
  return c * c * (3 - 2 * c);
}
function easeInOutCubic(t: number) {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

// arm rotation angle (deg, absolute, measured from the pivot's +x axis with
// the spindle at angle 180 from it) that puts the stylus at radius r_mm.
// Derived from r^2 = Lp^2 + l^2 + 2*Lp*l*cos(alpha) — real law-of-cosines
// geometry, not a fit.
function alphaForRadius(rMm: number) {
  const r = clamp(rMm, R_INNER_MM, R_OUTER_MM);
  const cosA = (r * r - LP_MM * LP_MM - L_EFF_MM * L_EFF_MM) / (2 * LP_MM * L_EFF_MM);
  return toDeg(Math.acos(clamp(cosA, -1, 1)));
}

// smooth interpolation through ERROR_KEYFRAMES, zero-derivative at each
// keyframe so a null crossing visibly settles rather than snapping through.
function errorForRadius(rMm: number) {
  const r = clamp(rMm, R_INNER_MM, R_OUTER_MM);
  for (let i = 0; i < ERROR_KEYFRAMES.length - 1; i++) {
    const [r0, e0] = ERROR_KEYFRAMES[i];
    const [r1, e1] = ERROR_KEYFRAMES[i + 1];
    if (r <= r0 && r >= r1) {
      const t = (r0 - r) / (r0 - r1);
      return lerp(e0, e1, smoothstep(t));
    }
  }
  return ERROR_KEYFRAMES[ERROR_KEYFRAMES.length - 1][1];
}

// stylus position in vb units for a given absolute arm angle (deg).
function stylusPoint(alphaDeg: number) {
  const a = toRad(alphaDeg);
  return {
    x: PV.x + L_EFF_VB * Math.cos(a),
    y: PV.y + L_EFF_VB * Math.sin(a),
  };
}

// every derived point painted each frame, for a given virtual radius and
// lift offset — shared by the rAF loop and the static t0 JSX below it, so
// the two can never drift apart.
function geometryFor(rMm: number, lift: number) {
  const alpha = alphaForRadius(rMm);
  const stylus = stylusPoint(alpha);
  const cwAngle = toRad(alpha + 180);
  const cw = {
    x: PV.x + CW_LEN_VB * Math.cos(cwAngle),
    y: PV.y + CW_LEN_VB * Math.sin(cwAngle),
  };
  const threadEnd = {
    x: PV.x + (stylus.x - PV.x) * 0.72,
    y: PV.y + (stylus.y - PV.y) * 0.72 - lift,
  };
  const tubeExt = {
    x: stylus.x + 16 * Math.cos(toRad(alpha)),
    y: stylus.y + 16 * Math.sin(toRad(alpha)),
  };
  const headshellRot = alpha + 180 + SKATE_LEAN_DEG;
  const err = errorForRadius(rMm);
  const needleX = 130 + (err / 2) * 110;
  return { alpha, stylus, cw, threadEnd, tubeExt, headshellRot, err, needleX };
}

// static t0 geometry (arm at the lead-in, no lift) — rendered inline in JSX
// so the SVG never commits a degenerate (0,0) frame before the first rAF
// tick, including in the server-rendered / pre-hydration HTML.
const T0 = geometryFor(R_OUTER_MM, 0);

// the arm's virtual radius (mm) and lift offset (vb) at elapsed time within
// one full cycle (sweep, hold, lift-and-return, settle). `airborne` marks
// the lift phase, where the stylus is off the groove and the tracking-error
// readout is not a real reading.
function stateForCycleT(cycleMs: number) {
  if (cycleMs < SWEEP_MS) {
    return {
      rMm: lerp(R_OUTER_MM, R_INNER_MM, cycleMs / SWEEP_MS),
      lift: 0,
      opacity: 1,
      airborne: false,
    };
  }
  if (cycleMs < SWEEP_MS + HOLD_MS) {
    return { rMm: R_INNER_MM, lift: 0, opacity: 1, airborne: false };
  }
  if (cycleMs < SWEEP_MS + HOLD_MS + LIFT_MS) {
    const p = (cycleMs - SWEEP_MS - HOLD_MS) / LIFT_MS;
    const pe = easeInOutCubic(p);
    const hump = Math.sin(Math.PI * p);
    return {
      rMm: lerp(R_INNER_MM, R_OUTER_MM, pe),
      lift: LIFT_PEAK_VB * hump,
      opacity: 1 - 0.12 * hump,
      airborne: true,
    };
  }
  return { rMm: R_OUTER_MM, lift: 0, opacity: 1, airborne: false };
}

// the frozen prefers-reduced-motion frame: arm pinned exactly at the first
// (inner) null, 66mm — needle centered, while a non-zero disc angle shows
// the disc has visibly turned, so the arm's non-radial rest is legible by
// contrast rather than looking like a static clock illustration.
const REDUCED_R_MM = NULL_INNER_MM;
const REDUCED_DISC_DEG = 135;

function fmtDeg(n: number) {
  if (Math.abs(n) < 0.05) return "0.0°"; // unsigned at the null, no +/- flicker
  const s = n > 0 ? "+" : "−";
  return `${s}${Math.abs(n).toFixed(1)}°`;
}

export interface TonearmSkateProps {
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function TonearmSkate({ className = "" }: TonearmSkateProps) {
  const discRef = useRef<SVGGElement>(null);
  const armLineRef = useRef<SVGLineElement>(null);
  const armGroupRef = useRef<SVGGElement>(null);
  const tubeExtRef = useRef<SVGLineElement>(null);
  const headshellRef = useRef<SVGRectElement>(null);
  const stylusDotRef = useRef<SVGCircleElement>(null);
  const cwRef = useRef<SVGCircleElement>(null);
  const threadRef = useRef<SVGLineElement>(null);
  const needleRef = useRef<SVGLineElement>(null);
  const readoutRef = useRef<HTMLSpanElement>(null);

  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;
    let raf = 0;
    let startTs: number | null = null;
    const onReducedChange = () => {
      reduced = mq.matches;
      // the loop stops rescheduling itself while reduced (one static paint
      // is enough); re-arm it here if motion is turned back on.
      if (!reduced && raf === 0) {
        startTs = null;
        raf = requestAnimationFrame(frame);
      }
    };
    mq.addEventListener("change", onReducedChange);

    function paint(
      rMm: number,
      discDeg: number,
      lift: number,
      armOpacity: number,
      airborne: boolean
    ) {
      const { stylus, cw, threadEnd, tubeExt, headshellRot, err, needleX } = geometryFor(
        rMm,
        lift
      );

      if (discRef.current) {
        discRef.current.setAttribute("transform", `rotate(${discDeg} ${SP.x} ${SP.y})`);
      }
      if (armGroupRef.current) {
        armGroupRef.current.setAttribute("transform", `translate(0 ${-lift})`);
        armGroupRef.current.setAttribute("opacity", String(armOpacity));
      }
      if (armLineRef.current) {
        armLineRef.current.setAttribute("x1", String(PV.x));
        armLineRef.current.setAttribute("y1", String(PV.y));
        armLineRef.current.setAttribute("x2", String(stylus.x));
        armLineRef.current.setAttribute("y2", String(stylus.y));
      }
      if (tubeExtRef.current) {
        tubeExtRef.current.setAttribute("x1", String(stylus.x));
        tubeExtRef.current.setAttribute("y1", String(stylus.y));
        tubeExtRef.current.setAttribute("x2", String(tubeExt.x));
        tubeExtRef.current.setAttribute("y2", String(tubeExt.y));
      }
      if (headshellRef.current) {
        headshellRef.current.setAttribute(
          "transform",
          `translate(${stylus.x} ${stylus.y}) rotate(${headshellRot})`
        );
      }
      if (stylusDotRef.current) {
        stylusDotRef.current.setAttribute("cx", String(stylus.x));
        stylusDotRef.current.setAttribute("cy", String(stylus.y));
      }
      if (cwRef.current) {
        cwRef.current.setAttribute("cx", String(cw.x));
        cwRef.current.setAttribute("cy", String(cw.y));
      }
      if (threadRef.current) {
        threadRef.current.setAttribute("x1", String(THREAD_ANCHOR.x));
        threadRef.current.setAttribute("y1", String(THREAD_ANCHOR.y));
        threadRef.current.setAttribute("x2", String(threadEnd.x));
        threadRef.current.setAttribute("y2", String(threadEnd.y));
      }

      // needle strip: error in [-2, 2] deg maps to x in [20, 240], weight
      // (stroke-width + opacity) scales with |error| / 2 — never accent.
      // While airborne (lifted between sides) the reading isn't real, so it
      // fades to near-invisible instead of whipping across the strip.
      const weight = clamp(Math.abs(err) / 2, 0, 1);
      if (needleRef.current) {
        needleRef.current.setAttribute("x1", String(needleX));
        needleRef.current.setAttribute("x2", String(needleX));
        needleRef.current.setAttribute("stroke-width", String(1.5 + weight * 1.6));
        needleRef.current.setAttribute("opacity", String(airborne ? 0.2 : 0.55 + weight * 0.45));
      }
      if (readoutRef.current) {
        readoutRef.current.textContent = airborne ? "—" : fmtDeg(err);
      }
    }

    function frame(ts: number) {
      if (reduced) {
        paint(REDUCED_R_MM, REDUCED_DISC_DEG, 0, 1, false);
        raf = 0; // repaint on demand only; the change listener re-arms this
        return;
      }
      if (startTs === null) startTs = ts;
      const elapsed = ts - startTs;
      const discDeg = ((elapsed % DISC_PERIOD_MS) / DISC_PERIOD_MS) * 360;
      const cycleT = elapsed % CYCLE_MS;
      const { rMm, lift, opacity, airborne } = stateForCycleT(cycleT);
      paint(rMm, discDeg, lift, opacity, airborne);
      raf = requestAnimationFrame(frame);
    }

    raf = requestAnimationFrame(frame);

    return () => {
      cancelAnimationFrame(raf);
      mq.removeEventListener("change", onReducedChange);
    };
  }, []);

  return (
    <div
      className={`w-full max-w-sm rounded-md border border-border bg-surface p-4 ${className}`}
    >
      <div className="mb-3 flex items-baseline justify-between gap-2">
        <p className="font-mono text-[11px] tracking-widest text-ns-muted">TONEARM</p>
        <p className="font-mono text-[10px] tracking-widest text-ns-muted">
          NULLS {NULL_INNER_MM} / {NULL_OUTER_MM}MM
        </p>
      </div>

      <div className="relative mx-auto aspect-square w-full max-w-[280px]">
        <svg viewBox={`0 0 ${VB} ${VB}`} className="h-full w-full" aria-hidden focusable="false">
          {/* disc: outer edge, groove-ring ticks, paper label, one foreground
              notch that is the sole visible proof of rotation */}
          <g ref={discRef}>
            <circle
              cx={SP.x}
              cy={SP.y}
              r={RECORD_R_VB}
              fill="none"
              className="stroke-current text-foreground"
              strokeWidth={1.25}
            />
            <circle
              cx={SP.x}
              cy={SP.y}
              r={R_OUTER_MM * SCALE}
              fill="none"
              className="stroke-current text-border"
              strokeWidth={0.75}
            />
            {/* the two null rings are load-bearing — they're what lets a
                viewer see the stylus reach a null the instant the needle
                centers, so they get foreground weight, not border */}
            <circle
              cx={SP.x}
              cy={SP.y}
              r={NULL_OUTER_MM * SCALE}
              fill="none"
              className="stroke-current text-foreground"
              strokeWidth={0.85}
              opacity={0.35}
            />
            <circle
              cx={SP.x}
              cy={SP.y}
              r={NULL_INNER_MM * SCALE}
              fill="none"
              className="stroke-current text-foreground"
              strokeWidth={0.85}
              opacity={0.35}
            />
            <circle
              cx={SP.x}
              cy={SP.y}
              r={R_INNER_MM * SCALE}
              fill="none"
              className="stroke-current text-border"
              strokeWidth={0.75}
            />
            {/* paper label */}
            <circle
              cx={SP.x}
              cy={SP.y}
              r={20}
              className="fill-current text-ns-muted"
              opacity={0.16}
            />
            {/* rotation notch — the one asymmetric mark, foreground so it
                stays visible in light theme */}
            <line
              x1={SP.x}
              y1={SP.y - 20}
              x2={SP.x}
              y2={SP.y - 27}
              className="stroke-current text-foreground"
              strokeWidth={1.5}
              strokeLinecap="round"
            />
            {/* spindle */}
            <circle cx={SP.x} cy={SP.y} r={2.5} className="fill-current text-foreground" />
          </g>

          {/* anti-skate post + thread live outside the lifted group — only
              the thread's far end (attached to the arm) rises with it, so
              the post it's anchored to never appears to detach */}
          <circle
            cx={THREAD_ANCHOR.x}
            cy={THREAD_ANCHOR.y}
            r={2.5}
            className="fill-current text-foreground"
            opacity={0.5}
          />
          <line
            ref={threadRef}
            x1={THREAD_ANCHOR.x}
            y1={THREAD_ANCHOR.y}
            x2={T0.threadEnd.x}
            y2={T0.threadEnd.y}
            className="stroke-current text-foreground"
            strokeWidth={0.75}
            strokeDasharray="2 2"
            opacity={0.45}
          />
          <g ref={armGroupRef}>
            {/* pivot mount — rigidly attached to the tube, so it lives in
                the lifted group with everything else that shares the pivot
                point; only the thread and its post (above) stay fixed */}
            <circle
              cx={PV.x}
              cy={PV.y}
              r={6}
              fill="none"
              className="stroke-current text-border"
              strokeWidth={1.25}
            />
            {/* counterweight */}
            <circle
              ref={cwRef}
              cx={T0.cw.x}
              cy={T0.cw.y}
              r={7}
              className="fill-current text-foreground"
              opacity={0.85}
            />
            {/* arm tube */}
            <line
              ref={armLineRef}
              x1={PV.x}
              y1={PV.y}
              x2={T0.stylus.x}
              y2={T0.stylus.y}
              className="stroke-current text-foreground"
              strokeWidth={2.25}
              strokeLinecap="round"
            />
            {/* tube-axis extension, uncanted — diverges visibly from the
                headshell body below to make the constant 3deg skate lean
                legible (a single rotated rect alone reads at well under a
                pixel of displacement) */}
            <line
              ref={tubeExtRef}
              x1={T0.stylus.x}
              y1={T0.stylus.y}
              x2={T0.tubeExt.x}
              y2={T0.tubeExt.y}
              className="stroke-current text-foreground"
              strokeWidth={0.75}
              opacity={0.3}
            />
            {/* headshell, canted by the constant skate lean */}
            <rect
              ref={headshellRef}
              x={-4}
              y={-3}
              width={22}
              height={6}
              rx={1.25}
              transform={`translate(${T0.stylus.x} ${T0.stylus.y}) rotate(${T0.headshellRot})`}
              className="fill-current text-foreground"
            />
            {/* stylus / contact point */}
            <circle
              ref={stylusDotRef}
              cx={T0.stylus.x}
              cy={T0.stylus.y}
              r={1.75}
              className="fill-current text-foreground"
            />
          </g>
        </svg>
      </div>

      <div className="mt-4 flex items-baseline justify-between gap-3">
        <p className="font-mono text-[11px] tracking-widest text-ns-muted">TRACKING ERROR</p>
        <span
          ref={readoutRef}
          className="font-mono text-[11px] font-semibold tabular-nums text-foreground"
        >
          {fmtDeg(T0.err)}
        </span>
      </div>

      <svg viewBox="0 0 260 24" className="mt-1 h-6 w-full" aria-hidden focusable="false">
        <line x1={20} x2={240} y1={12} y2={12} className="stroke-current text-border" strokeWidth={1} />
        <line x1={20} x2={20} y1={7} y2={17} className="stroke-current text-border" strokeWidth={1} />
        <line x1={240} x2={240} y1={7} y2={17} className="stroke-current text-border" strokeWidth={1} />
        <line x1={130} x2={130} y1={5} y2={19} className="stroke-current text-foreground" strokeWidth={1} opacity={0.4} />
        <line
          ref={needleRef}
          x1={T0.needleX}
          x2={T0.needleX}
          y1={4}
          y2={20}
          strokeWidth={1.5 + clamp(Math.abs(T0.err) / 2, 0, 1) * 1.6}
          className="stroke-current text-foreground"
          strokeLinecap="round"
        />
      </svg>

      <p className="mt-2 font-mono text-[10px] leading-relaxed text-ns-muted">
        lead-in {R_OUTER_MM}mm &middot; run-out {R_INNER_MM}mm &mdash; error crosses zero at each
        null, growing toward both edges
      </p>
    </div>
  );
}

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/tonearm-skate.tsx, so point the import there.

Use when a read-only 'how well-aligned is this' quality/tracking gauge where the interesting content is a geometric error swept across a continuous range with two exact zero points, not a plain live value. Pick meter-threshold-trip instead for a data-driven metric with a single trip/re-arm threshold pair and no radius/arc domain at all — it has no sweeping geometry, just a value crossing two flat marks. Pick dial-moire instead for a user-driven rotary control whose feedback is optical alignment under drag, not an ambient, autonomous, always-sweeping error function with zero user input.

Install

npx shadcn add https://design.helpmarq.com/r/tonearm-skate.json

Ask AI

Point an assistant at this component's docs (llms-full.txt) with one click.

Claude, ChatGPT, Grok, and Perplexity open with the prompt already in. Gemini copies it to your clipboard first. Paste it in once the chat opens.

Build spec

Renders one autonomous, data-free instrument (no props drive its motion) built on the real geometry of a pivoted phono tonearm: LP_MM=215 (pivot-to-spindle) and L_EFF_MM=225 (effective pivot-to-stylus length) are real constants for a standard 9" arm, and the arm's absolute rotation angle for any target stylus radius r is derived from the true law-of-cosines relationship r^2 = Lp^2 + l^2 + 2*Lp*l*cos(alpha) (alphaForRadius, solved via acos, not fit or approximated) — the same geometry that places this pivot distance's two Baerwald alignment nulls at 66mm and 120mm. A single rAF loop (no per-frame setState; every mutation is a direct ref.setAttribute call) derives an elapsed-time value from the frame's own timestamp (never accumulated per-frame deltas, so a backgrounded tab cannot drift the arm out of sync with the disc) and drives two independent clocks off it: the record disc rotates continuously at 1 rev/4000ms via a rotate() transform on a <g> around the spindle point SP, regardless of what the arm is doing (a real turntable motor never stops between sides); and the arm runs a 50400ms cycle (stateForCycleT) — a 48000ms linear sweep of its target radius from the 146mm lead-in groove down to the 60mm run-out groove, a 700ms hold at run-out, a 1400ms eased lift-and-return (easeInOutCubic on the radius, plus a sin(pi*p)-shaped vertical hop capped at LIFT_PEAK_VB and a matching opacity dip, so the pickup/swing/set-down reads as continuous motion, never a jump-cut) back to the lead-in, and a 300ms settle before the next side begins — unbounded, no dead stop. alphaForRadius(targetRadius) converts that virtual radius into the arm's absolute pivot angle every frame; stylusPoint(alpha) places the stylus in scene coordinates; the counterweight (a filled circle rigidly opposite the headshell across the pivot at a fixed CW_LEN_VB radius) and a dashed anti-skate thread (from a fixed post near the pivot to a point 72% along the current arm-tube line) are recomputed from that same alpha every frame, so they swing in lockstep with the arm rather than being static decoration. The headshell <rect> is rotated to the tube's angle plus a constant SKATE_LEAN_DEG=3 cant that never changes with value or time — the skating force is a standing offset, not a discrete event — while the small stylus-contact <circle> stays exactly on the tube's true endpoint, unrotated by the lean, since that circle is the actual groove-contact point. A second, independently driven readout is the tracking-error strip below the disc: errorForRadius(r) walks a five-point keyframe table (146mm:+2deg, 120mm:0, 93mm:-0.7deg, 66mm:0, 60mm:-2deg — anchored to the real null radii and the documented +/-2deg edge magnitudes, with the 93mm interior lobe set to roughly a third of the edge magnitude so the needle visibly leaves and returns to center between the nulls rather than reading as a flat line for the 63% of the sweep that separates them) and interpolates between adjacent keyframes with a smoothstep(t) (zero derivative at every keyframe, so the needle visibly decelerates into and pauses at each null before departing again — that pause is what makes the crossing legible as 'it centered' rather than a blip) rather than a straight lerp. The needle itself is a single <line> whose x position maps error from [-2,2]deg linearly to viewBox x in [20,240]; only its stroke-width (1.5 to 3.1) and opacity (0.55 to 1.0) scale with |error|/2 — it is stroke-current text-foreground always, never text-ns-accent, since a higher error reads as heavier/darker, not as interaction chrome. Disc, arm tube, headshell, counterweight, stylus dot and the fixed pivot mount are all stroke-current or fill-current classes on text-foreground; the disc's groove-ring circles (at the lead-in, both null, and run-out radii) and the anti-skate thread are text-border — pure separator use, never load-bearing fill; the paper label circle is text-ns-muted at low opacity. The disc's rotation would otherwise be visually silent (four concentric circles have full rotational symmetry) so a single short radial notch just outside the paper label is drawn text-foreground specifically so it stays visible against a light theme and is the one asymmetric mark that proves the disc is turning. Under prefers-reduced-motion (matchMedia with a live 'change' listener; the rAF loop keeps running so the disc-independent-of-arm relationship never has to be reasoned about statically) the whole scene freezes on a deliberately chosen non-t0 frame: the arm pinned at exactly the 66mm inner null (needle reading 0.0deg dead center) while the disc is held at a fixed 135deg rotation rather than its start angle — chosen because that single frame shows both 'the geometry is exactly correct here' (centered needle) and, by the arm's visibly non-radial position against an already-turned disc, that error exists everywhere else along the sweep. Geometry is derived from the SVG's own viewBox (300x300, fixed internal units) scaled from real millimetres via SCALE = 96/150 (record radius in viewBox units over the real 150mm LP radius), and the whole scene sits in a wrapper with aspect-square and max-w-[280px] so it reads at card scale regardless of container width. Every point painted per frame (stylus, counterweight, anti-skate thread end, tube-axis extension, headshell rotation, needle x, numeric error) comes from one `geometryFor(radius, lift)` function shared by the rAF loop and a module-scope `T0 = geometryFor(R_OUTER_MM, 0)` constant that is inlined directly as the initial JSX attributes on every ref'd element (arm tube, headshell transform, stylus dot, counterweight, thread, needle, readout text) — without it, every ref'd SVG primitive would default to (0,0)/zero-length until the first rAF tick, and since these are client components under App Router that wrong frame would sit in the pre-hydration server HTML, not just flash for one frame; this is the geometric form of the binding rule against painting before the first read. No canvas anywhere, no color literal anywhere, no getComputedStyle/MutationObserver machinery — nothing here is a raster surface, every paint is a token Tailwind class, so a theme flip repaints for free. Cleanup cancels the single rAF and removes the matchMedia listener on unmount. The root element carries no role/aria-label of its own — every SVG is aria-hidden, but the visible plain-text label row ('TONEARM' / null radii), the live numeric error readout (a plain HTML span, not SVG text, updated via textContent each frame), and the lead-in/run-out/null caption below the disc are ordinary DOM text nodes an AT already reads correctly; wrapping the root in role=img would collapse all of that into a single opaque string, which is why it's deliberately absent (matching barograph-drum-week, which also leaves its root unlabelled and aria-hides only the non-text layers). Props are `className` only — this is an autonomous instrument, not a data-bound meter; it takes no `value` and exposes no drag interaction (the spec's optional scrub-to-inspect affordance was deliberately dropped, since it is the only path by which text-ns-accent could leak onto the needle, and the passive resting loop already carries the full legibility requirement on its own). The anti-skate thread and its post, and the pivot mount circle, are rendered as fixed siblings outside the lifted arm group — only the thread's far end (recomputed from the arm's live position minus the current lift offset) moves, so the thread never visibly detaches from its anchor during the lift-and-return phase. The 3deg skate lean would be sub-pixel on the headshell rect alone, so a second, uncanted hairline continues the true tube axis a further 16vb past the stylus — the two nearly-parallel lines' visible divergence is what makes the constant lean legible, not the rect's rotation by itself. During the 1400ms lift-and-return phase (airborne=true) the tracking-error needle fades to a fixed 0.2 opacity and the numeric readout shows an em dash instead of a number, since the stylus is off the groove and the formula's output during that phase is not a real reading — without that suppression the return sweep would manufacture two fast, meaningless null-crossing-looking events every 50.4s loop. Under prefers-reduced-motion the rAF loop paints the frozen frame once and then stops rescheduling itself (no wasted per-frame work animating an unchanging scene); the matchMedia 'change' listener re-arms a fresh rAF loop if motion is turned back on mid-session.

Props

PropTypeDefaultDescription
className?string—extra classes merged onto the rendered root element