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.

Usage

registry/core/groove-pitch/demo.tsx
"use client";

import { GroovePitch } from "./component";

export default function GroovePitchDemo() {
  return (
    <main className="flex min-h-screen flex-col items-center justify-center gap-12 bg-background px-6 py-16">
      <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
        ns-ui / groove-pitch
      </p>

      <div
        data-groove-pitch-hero
        className="flex w-full max-w-md flex-col items-center gap-6 rounded-xl border border-border bg-surface px-10 py-12"
      >
        <GroovePitch label="Rendering export" />
        <p className="text-sm text-foreground">Rendering export</p>
        <p className="font-mono text-[11px] text-ns-muted">
          turn spacing widens and narrows like a cutting lathe under program level
        </p>
      </div>
    </main>
  );
}

Component

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>
  );
}

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

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.

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?string—extra classes merged onto the rendered root element