Skip to main content

ns-ui

Groove Pitch

An ambient loader modeling a vinyl mastering lathe's variable groove pitch: a cutting-point marker spirals outward from a lead-in, its own turn spacing visibly widening over loud passages and tightening over quiet ones, completing a side and resetting to lead-in on an unbounded loop.

Use when an indeterminate ambient loader whose identity is a real mastering-lathe artifact — groove-pitch modulation, not a percentage — for a wait with no known total. Pick loader-spirograph-trace instead when the wait needs an actual 0-100 percentage traced along a fixed closed curve (the rosette is always fully visible as a ghost and progress is arc length inked in); pick loader-thread-spool instead when the wait genuinely starts unknown and must convert in place into a proportional gauge the instant a total arrives — groove-pitch never becomes determinate, it only loops.

Install

npx shadcn add https://design.helpmarq.com/r/groove-pitch.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/groove-pitch/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// GroovePitch — an ambient, indeterminate loader modeling a real mastering-
// lathe artifact: variable groove pitch. A cutting lathe varies the radial
// spacing between adjacent spiral turns in real time, based on program
// level, so loud/bassy passages get pulled wider (fewer grooves per inch,
// avoiding wall-to-wall breakthrough) and quiet passages get packed tight.
// The spiral here grows outward from a small lead-in at the center, its own
// turn spacing continuously widening and narrowing on a baked envelope, and
// a bright cutting-point marker rides its leading edge.
//
// Real vs. rendered rates are deliberately decoupled (round-9 legibility
// rule): real lathe rotation is 33 1/3 RPM and real pitch varies ~2x across
// ~40 grooves/mm — both documented below, neither animated 1:1. The
// rendered sweep is slowed to something a viewer can actually track: one
// revolution per 3s, one full "side" (lead-in to outer edge) over 42s, and
// a pitch-band transition roughly every 4s.
//
// Zero colour literals: --foreground is read via getComputedStyle at mount
// and re-read on a MutationObserver watching documentElement's class, with
// no paint before that first read. Canvas is DPR-capped, resized via
// ResizeObserver, paused via IntersectionObserver + visibilitychange.
// prefers-reduced-motion freezes on one drawn frame at 35% traversal,
// parked right where the pitch is visibly mid-transition.
// ---------------------------------------------------------------------------

const ANGULAR_RATE = (Math.PI * 2) / 3; // rad/s — 1 rendered rev / 3s (real: 33 1/3 RPM, documented only)
const TRAVERSAL_S = 42; // s — one full lead-in-to-edge sweep before reset
const PITCH_PERIOD_S = 8; // s — one wide/narrow modulation cycle (transition every ~4s)
const PITCH_RATIO = 5.8 / 2.2; // reference contrast at typical card scale, preserved at any size
const LEAD_IN_FRAC = 0.055; // inner radius as a fraction of the max drawn radius
const STATIC_PROGRESS = 0.35; // reduced-motion freeze: traversal fraction, mid pitch-transition

function parseHex(raw: string): [number, number, number] | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

/** Radial gain per revolution at traversal-time t, oscillating between the
 * ratio's two poles around a mid pitch sized so the spiral's outer edge is
 * reached almost exactly at TRAVERSAL_S regardless of container size. */
function pitchAt(t: number, midPitch: number): number {
  const amp = (midPitch * (PITCH_RATIO - 1)) / (PITCH_RATIO + 1);
  return midPitch + amp * Math.sin((Math.PI * 2 * t) / PITCH_PERIOD_S);
}

/** Sample the spiral's polyline points, its own leading-edge angle/radius,
 * and the total revolutions swept, from t = 0 up to `tEnd` (traversal
 * seconds). Deterministic integration — same result every call for the same
 * tEnd, which is what lets the reduced-motion path just call it once. */
function buildSpiral(tEnd: number, maxR: number, r0: number, midPitch: number) {
  const dt = 1 / 60; // integration step, independent of the caller's paint rate
  let t = 0;
  let theta = 0;
  let r = r0;
  const pts: { x: number; y: number }[] = [];
  pts.push({ x: r, y: 0 });
  while (t < tEnd && r < maxR) {
    const dTheta = ANGULAR_RATE * dt;
    const pitch = pitchAt(t, midPitch);
    r += (pitch / (Math.PI * 2)) * dTheta;
    theta += dTheta;
    t += dt;
    pts.push({ x: r * Math.cos(theta), y: r * Math.sin(theta) });
  }
  return { pts, theta, r: Math.min(r, maxR) };
}

export interface GroovePitchProps {
  /** accessible label for the loading status. @default "Loading" */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function GroovePitch({ label = "Loading", className = "" }: GroovePitchProps) {
  const rootRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let raf = 0;
    let dpr = 1;
    let w = 0;
    let h = 0;
    let intersecting = true;
    let pageVisible = document.visibilityState === "visible";
    let visible = intersecting && pageVisible;
    let startedAt = 0;

    let fg: [number, number, number] = [23, 23, 23];

    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fg = parseHex(cs.getPropertyValue("--foreground").trim()) ?? fg;
    };
    readTokens();

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;

    const resize = () => {
      const rect = root.getBoundingClientRect();
      w = rect.width;
      h = rect.height;
      dpr = Math.min(2, window.devicePixelRatio || 1);
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      const elapsed = startedAt === 0 ? 0 : performance.now() / 1000 - startedAt;
      draw(reduced ? STATIC_PROGRESS * TRAVERSAL_S : elapsed);
    };

    // geometry keyed off the container's SMALLER dimension, so this reads
    // at card scale rather than only at full-bleed
    const geom = () => {
      const minDim = Math.min(w, h);
      const maxR = minDim * 0.425; // spiral fits inside 0.85 * minDim diameter
      const r0 = maxR * LEAD_IN_FRAC;
      const midPitch = (maxR - r0) / ((TRAVERSAL_S * ANGULAR_RATE) / (Math.PI * 2));
      return { maxR, r0, midPitch };
    };

    const draw = (tSeconds: number) => {
      if (w <= 0 || h <= 0) return;
      const { maxR, r0, midPitch } = geom();
      const t = Math.max(0, Math.min(TRAVERSAL_S, tSeconds % TRAVERSAL_S));
      const { pts } = buildSpiral(t, maxR, r0, midPitch);

      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      ctx.clearRect(0, 0, w, h);
      ctx.save();
      ctx.translate(w / 2, h / 2);

      ctx.beginPath();
      for (let i = 0; i < pts.length; i++) {
        const p = pts[i];
        if (i === 0) ctx.moveTo(p.x, p.y);
        else ctx.lineTo(p.x, p.y);
      }
      ctx.strokeStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},0.85)`;
      ctx.lineWidth = Math.max(1, Math.min(w, h) * 0.006);
      ctx.lineJoin = "round";
      ctx.lineCap = "round";
      ctx.stroke();

      // the cutting-point marker riding the spiral's leading edge — the one
      // thing a viewer should follow
      const tip = pts[pts.length - 1];
      if (tip) {
        ctx.beginPath();
        ctx.arc(tip.x, tip.y, Math.max(2, Math.min(w, h) * 0.018), 0, Math.PI * 2);
        ctx.fillStyle = `rgba(${fg[0]},${fg[1]},${fg[2]},1)`;
        ctx.fill();
      }

      ctx.restore();
    };

    const loop = (now: number) => {
      if (!visible || reduced) {
        raf = 0;
        return;
      }
      if (startedAt === 0) startedAt = now / 1000;
      draw(now / 1000 - startedAt);
      raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (raf === 0 && !reduced && visible) raf = requestAnimationFrame(loop);
    };

    resize();
    if (reduced) draw(STATIC_PROGRESS * TRAVERSAL_S);
    else wake();

    const ro = new ResizeObserver(resize);
    ro.observe(root);

    const mo = new MutationObserver(() => {
      readTokens();
      const elapsed = startedAt === 0 ? 0 : performance.now() / 1000 - startedAt;
      draw(reduced ? STATIC_PROGRESS * TRAVERSAL_S : elapsed);
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    const io = new IntersectionObserver(
      (entries) => {
        intersecting = entries[0]?.isIntersecting ?? true;
        visible = intersecting && pageVisible;
        if (visible) wake();
        else {
          cancelAnimationFrame(raf);
          raf = 0;
        }
      },
      { threshold: 0 }
    );
    io.observe(root);

    const onVisibility = () => {
      pageVisible = document.visibilityState === "visible";
      visible = intersecting && pageVisible;
      if (visible) wake();
      else {
        cancelAnimationFrame(raf);
        raf = 0;
      }
    };
    document.addEventListener("visibilitychange", onVisibility);

    const onReducedChange = () => {
      reduced = mq.matches;
      if (reduced) {
        cancelAnimationFrame(raf);
        raf = 0;
        draw(STATIC_PROGRESS * TRAVERSAL_S);
      } else {
        startedAt = 0;
        wake();
      }
    };
    mq.addEventListener("change", onReducedChange);

    return () => {
      cancelAnimationFrame(raf);
      ro.disconnect();
      mo.disconnect();
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
      mq.removeEventListener("change", onReducedChange);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      role="status"
      aria-label={label}
      data-groove-pitch
      className={`relative aspect-square w-full max-w-[220px] ${className}`}
    >
      <canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
    </div>
  );
}
Build spec

Build <GroovePitch label? className?> as a self-contained ambient loader, zero dependencies, 2D canvas only (no SVG, no WebGL). MECHANISM: models a real vinyl mastering lathe's variable-pitch cutting — the lathe varies the radial spacing between adjacent spiral turns in real time based on program level, pulling pitch wider over loud/bassy passages (fewer grooves per inch, avoiding wall-to-wall breakthrough) and packing it tighter over quiet ones. Render a single continuous spiral polyline growing outward from a small lead-in radius at the canvas center, integrated in polar form: at each fixed 1/60s integration step, advance theta by ANGULAR_RATE * dt (ANGULAR_RATE = 2*PI/3 rad/s, i.e. one rendered revolution per 3s — deliberately decoupled from the real lathe's 33 1/3 RPM, which is documented in a code comment only and never animated 1:1, per the round-9 aliasing rule) and grow radius by (pitch(t) / (2*PI)) * dTheta, where pitch(t) = midPitch + amp*sin(2*PI*t/8) oscillates on an 8s period (amp/midPitch = (5.8/2.2 - 1)/(5.8/2.2 + 1) ~= 0.45, preserving the >2.5x pitch contrast ratio from the real reference numbers at any container size) so a pitch-band transition is visible roughly every 4s. midPitch is derived, not fixed: (maxRadius - leadInRadius) / 14 (14 = total revolutions over the 42s traversal), so the spiral's outer edge is reached at essentially TRAVERSAL_S = 42s regardless of container size. maxRadius = 0.425 * min(containerWidth, containerHeight) (spiral fits inside a circle 0.85x the container's smaller dimension); leadInRadius = 0.055 * maxRadius. Traversal time wraps modulo 42s — once the spiral reaches its outer edge it resets to the lead-in and a fresh 'side' begins, an unbounded loop, never a finished/stopped state. Draw the spiral as a single stroked path (var(--foreground) at 0.85 opacity, round joins/caps, line width ~0.6% of the container's smaller dimension) plus one filled circle marker (var(--foreground) at full opacity, radius ~1.8% of the smaller dimension) at the spiral's current leading edge/tip — this marker is the one thing a viewer should track by eye. RENDERING: canvas is w-full h-full inside a relative aspect-square wrapper (default max-w-[220px], overridable via className), DPR-capped backing store (min(2, devicePixelRatio)) resized via ResizeObserver on the wrapper. Colour is read once via getComputedStyle(document.documentElement).getPropertyValue('--foreground') before the first paint, and re-read on a MutationObserver watching documentElement's class attribute — no colour literal anywhere, no paint before that first read. The rAF loop is paused (cancelAnimationFrame, not just skipped) whenever an IntersectionObserver (threshold 0) reports the wrapper off-screen OR document.visibilityState !== 'visible', and resumes from wherever real elapsed time now places it (never resets phase) when both become true again. prefers-reduced-motion: reduce disables the rAF loop entirely and draws exactly one frame at traversal fraction 0.35 (14.7s into the 42s cycle) — chosen because it lands mid pitch-transition, the single frame that shows the turn spacing visibly different from its immediate neighbors rather than a frame where pitch looks locally constant; a live matchMedia 'change' listener re-derives this on an OS-level reduced-motion toggle. Root element is role='status' aria-label={label} (default 'Loading'); the canvas itself is aria-hidden. No pointer or keyboard interaction — this is a pure ambient/display loader, nothing to focus-ring, var(--ns-accent) never appears anywhere in it. Props: label (string, default 'Loading'), className (merged onto the root wrapper, e.g. to override max-w-[220px] or aspect-square).

Props

PropTypeDefaultDescription
label?string"Loading"accessible label for the loading status. @default "Loading"
className?stringextra classes merged onto the rendered root element