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.

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.

Source
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>
  );
}
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?stringextra classes merged onto the rendered root element