Loader Spirograph Trace

Loader

A determinate loader that traces a real hypotrochoid: the full closed rosette is always visible as a ghost, and progress is the fraction of that curve's arc length inked in, so 0% and 100% are both legible shapes rather than an empty ring.

Install
npx shadcn add https://design.helpmarq.com/r/loader-spirograph-trace.json
Source
registry/core/loader-spirograph-trace/component.tsx
"use client";

// ---------------------------------------------------------------------------
// SpiroTrace — a determinate loader that inks in a real hypotrochoid.
//
// The curve is the classic spirograph pen path:
//   x(phi) = (R - r)cos(phi) + d*cos(((R - r)/r) * phi)
//   y(phi) = (R - r)sin(phi) - d*sin(((R - r)/r) * phi)
// With R = 5, r = 3 (gcd 1) the pen closes after r/gcd = 3 revolutions, so phi
// sweeps 0 -> 6*PI and the result is a closed five-petal rosette (petals =
// R/gcd = 5). It is sampled once into a single "M ... L ... Z" path string,
// memoised on R/r/d, and fitted to a 0 0 100 100 viewBox with a 6-unit margin.
//
// The whole rosette is ALWAYS on screen as a faint ghost; progress is simply
// the fraction of that curve's ARC LENGTH inked on top of it (pathLength="1",
// dasharray "1 1", dashoffset 1 - progress). So 0% and 100% are both legible
// shapes, and the route the loader will take is readable before it moves.
//
// Indeterminate is not a different widget: the same curve keeps a fixed
// 0.18-length dash window travelling around it at a constant arc-length rate.
// ---------------------------------------------------------------------------

import { useEffect, useMemo, useRef } from "react";

const SAMPLES = 720; // points sampled along the closed curve
const MARGIN = 6; // viewBox units of breathing room around the fitted rosette
const VIEW = 100;
const GLIDE_MS = 260; // determinate ease, matched to the CSS transition below
const SWEEP_MS = 2400; // one full traversal in indeterminate mode
const DASH = 0.18; // indeterminate window, as a fraction of arc length

const CSS = `
.ns-sg-trace{transition:stroke-dashoffset ${GLIDE_MS}ms cubic-bezier(.33,1,.68,1)}
.ns-sg-sweep{animation:ns-sg-sweep ${SWEEP_MS}ms linear infinite}
@keyframes ns-sg-sweep{from{stroke-dashoffset:0}to{stroke-dashoffset:-1}}
@media (prefers-reduced-motion: reduce){
  .ns-sg-trace{transition:none}
  .ns-sg-sweep{animation:none}
}
`;

function gcd(a: number, b: number): number {
  return b === 0 ? Math.abs(a) : gcd(b, a % b);
}

function easeOutCubic(p: number): number {
  return 1 - (1 - p) ** 3;
}

/** Sample the hypotrochoid once and fit it into the viewBox with a margin. */
function buildRosette(R: number, r: number, d: number): string {
  const turns = Math.max(1, Math.round(r / gcd(Math.round(R), Math.round(r))));
  const phiMax = 2 * Math.PI * turns;
  const xs: number[] = [];
  const ys: number[] = [];
  for (let i = 0; i < SAMPLES; i++) {
    const phi = (i / SAMPLES) * phiMax;
    xs.push((R - r) * Math.cos(phi) + d * Math.cos(((R - r) / r) * phi));
    ys.push((R - r) * Math.sin(phi) - d * Math.sin(((R - r) / r) * phi));
  }
  let minX = Infinity;
  let maxX = -Infinity;
  let minY = Infinity;
  let maxY = -Infinity;
  for (let i = 0; i < SAMPLES; i++) {
    if (xs[i] < minX) minX = xs[i];
    if (xs[i] > maxX) maxX = xs[i];
    if (ys[i] < minY) minY = ys[i];
    if (ys[i] > maxY) maxY = ys[i];
  }
  const span = VIEW - MARGIN * 2;
  const scale = Math.min(span / (maxX - minX || 1), span / (maxY - minY || 1));
  const ox = (VIEW - (maxX - minX) * scale) / 2 - minX * scale;
  const oy = (VIEW - (maxY - minY) * scale) / 2 - minY * scale;
  const pt = (i: number) =>
    `${(xs[i] * scale + ox).toFixed(2)} ${(ys[i] * scale + oy).toFixed(2)}`;
  let out = `M ${pt(0)}`;
  for (let i = 1; i < SAMPLES; i++) out += ` L ${pt(i)}`;
  return `${out} Z`;
}

export interface SpiroTraceProps {
  /** progress 0-100. Leave undefined for indeterminate (a travelling sweep). */
  value?: number;
  /** glyph size in px. */
  size?: number;
  /** accessible name for the progressbar. */
  label?: string;
  /** fixed radius of the spirograph ring. */
  R?: number;
  /** rolling radius. gcd(R, r) = 1 keeps the rosette a single closed curve. */
  r?: number;
  /** pen offset from the rolling circle's centre. */
  d?: number;
  className?: string;
}

export function SpiroTrace({
  value,
  size = 160,
  label = "Loading",
  R = 5,
  r = 3,
  d = 2.1,
  className = "",
}: SpiroTraceProps) {
  const indeterminate = value == null || Number.isNaN(value);
  const target = indeterminate ? 0 : Math.min(100, Math.max(0, value ?? 0)) / 100;

  const path = useMemo(() => buildRosette(R, r, d), [R, r, d]);
  // the curve's first sampled point, parsed straight back out of the path
  // string: it is where the pen is parked before the effect measures the
  // path, so the very first painted frame already shows the dot on the curve
  // rather than off-canvas.
  const start = useMemo(() => {
    const m = path.slice(2, path.indexOf(" L")).split(" ");
    return { x: Number(m[0]), y: Number(m[1]) };
  }, [path]);

  const ghostRef = useRef<SVGPathElement>(null);
  const traceRef = useRef<SVGPathElement>(null);
  const penRef = useRef<SVGCircleElement>(null);
  // last progress actually painted, in arc-length fraction — the indeterminate
  // sweep keeps writing to it, which is how a real value converges from where
  // the window happened to be rather than restarting from zero.
  const displayRef = useRef(indeterminate ? 0 : target);
  const wasIndetRef = useRef(indeterminate);

  useEffect(() => {
    const ghost = ghostRef.current;
    const trace = traceRef.current;
    const pen = penRef.current;
    if (!ghost || !trace || !pen) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const total = ghost.getTotalLength();
    let raf = 0;

    const placePen = (p: number) => {
      const at = ((p % 1) + 1) % 1;
      const pt = ghost.getPointAtLength(at * total);
      pen.setAttribute("cx", pt.x.toFixed(2));
      pen.setAttribute("cy", pt.y.toFixed(2));
    };

    if (indeterminate) {
      trace.style.transition = "";
      trace.style.strokeDashoffset = "0";
      wasIndetRef.current = true;
      if (reduced) {
        // static window: the same shape, frozen at a fixed offset, still legible
        displayRef.current = DASH;
        placePen(DASH);
        return;
      }
      const start = performance.now();
      const loop = (now: number) => {
        // constant arc-length rate: one full traversal per SWEEP_MS
        const head = (((now - start) / SWEEP_MS) % 1) + DASH;
        displayRef.current = head % 1;
        placePen(head);
        raf = requestAnimationFrame(loop);
      };
      raf = requestAnimationFrame(loop);
      return () => cancelAnimationFrame(raf);
    }

    const from = displayRef.current;

    if (wasIndetRef.current) {
      // Coming out of the sweep: seed the inline dashoffset at wherever the
      // window's head was, with the transition suppressed for that one write,
      // so the following write to `target` glides from there instead of
      // snapping back to an empty ring.
      trace.style.transition = "none";
      trace.style.strokeDashoffset = String(1 - from);
      void trace.getBoundingClientRect(); // force a style recalc before re-enabling
      trace.style.transition = "";
      wasIndetRef.current = false;
    }
    trace.style.strokeDashoffset = String(1 - target);

    if (reduced || Math.abs(target - from) < 1e-4) {
      displayRef.current = target;
      placePen(target);
      return;
    }

    // the pen only needs a loop while progress is in flight; it stops after
    const startedAt = performance.now();
    const loop = (now: number) => {
      const p = Math.min(1, (now - startedAt) / GLIDE_MS);
      const at = from + (target - from) * easeOutCubic(p);
      displayRef.current = at;
      placePen(at);
      raf = p < 1 ? requestAnimationFrame(loop) : 0;
    };
    raf = requestAnimationFrame(loop);
    return () => cancelAnimationFrame(raf);
  }, [indeterminate, target, path]);

  const pct = Math.round(target * 100);

  return (
    <div
      role="progressbar"
      aria-label={label}
      aria-valuemin={0}
      aria-valuemax={100}
      // omitted entirely while indeterminate — that absence IS the signal
      aria-valuenow={indeterminate ? undefined : pct}
      data-spiro-trace
      className={`inline-flex items-center gap-4 ${className}`}
    >
      <style>{CSS}</style>
      <svg
        viewBox={`0 0 ${VIEW} ${VIEW}`}
        width={size}
        height={size}
        aria-hidden="true"
        focusable="false"
        className="shrink-0 overflow-visible"
      >
        {/* the whole closed rosette, always visible: the route, not a track */}
        <path
          ref={ghostRef}
          d={path}
          fill="none"
          stroke="var(--foreground)"
          strokeOpacity={0.22}
          strokeWidth={1.1}
          strokeLinejoin="round"
        />
        {/* the inked fraction of that same curve */}
        <path
          ref={traceRef}
          className={`ns-sg-trace${indeterminate ? " ns-sg-sweep" : ""}`}
          d={path}
          pathLength={1}
          fill="none"
          stroke="var(--foreground)"
          strokeOpacity={0.92}
          strokeWidth={1.6}
          strokeLinecap="round"
          strokeLinejoin="round"
          strokeDasharray={indeterminate ? `${DASH} ${1 - DASH}` : "1 1"}
          style={{ strokeDashoffset: indeterminate ? 0 : 1 - target }}
        />
        {/* the pen, riding the curve at the current position */}
        <circle ref={penRef} r={2.2} cx={start.x} cy={start.y} fill="var(--accent)" />
      </svg>

      <span aria-hidden="true" className="font-mono text-sm tabular-nums text-muted">
        {indeterminate ? "———" : `${pct}%`}
      </span>
    </div>
  );
}
Use when

a determinate circular loader where the route matters as much as the reading — the whole closed spirograph rosette is on screen as a ghost at 0%, so the empty state is a shape rather than a bare ring, and the same curve carries an arc-length sweep when no total is known yet. Pick loader-iris instead for a small inline indeterminate glyph in a fixed square footprint with no percentage to show, progress-wick when the progress is a horizontal transport bar whose real cadence is bursty, and loader-thread-spool when the wait starts unknown and the object should visibly become a gauge once a total arrives.

Build spec

Build <SpiroTrace value? size? label? R? r? d? className?> as a plain SVG progressbar with zero dependencies and no canvas. CURVE: a hypotrochoid with R = 5, r = 3, d = 2.1 — x(phi) = (R - r)*cos(phi) + d*cos(((R - r)/r)*phi), y(phi) = (R - r)*sin(phi) - d*sin(((R - r)/r)*phi). Because gcd(R, r) = 1 the pen closes after exactly r/gcd(R,r) = 3 revolutions, so phi runs 0 -> 6*PI and the result is a single closed five-petal rosette (petal count = R/gcd = 5). It is sampled ONCE at 720 points into one 'M ... L ... Z' path string (memoised on the R/r/d props), then measured and affinely fitted into a 0 0 100 100 viewBox with a 6-unit margin, uniform scale, centred. RENDERING PROGRESS: two <path> elements share that exact same `d`. The ghost path is stroke="var(--foreground)" strokeOpacity 0.22, strokeWidth 1.1 — the entire route, always on screen, never a circular track. The traced path sits on top with pathLength="1" (so dash units are normalized ARC LENGTH, not user units), strokeDasharray="1 1", strokeDashoffset={1 - progress}, strokeOpacity 0.92, strokeWidth 1.6, strokeLinecap="round", and a 260ms cubic-bezier(.33,1,.68,1) CSS transition on stroke-dashoffset so an incoming value glides along the curve instead of jumping. A pen dot (r = 2.2, fill="var(--accent)" — the single accent use in the whole component) rides the curve at the current position, located with ghostPath.getPointAtLength(progress * getTotalLength()) inside a rAF that runs ONLY while progress is in flight and then stops itself; a settled or unchanged value places the pen once and schedules no frames. INDETERMINATE: when `value` is undefined (or NaN) the dashoffset binding is dropped and the same curve instead carries a fixed 0.18-length dash window (strokeDasharray="0.18 0.82") driven around the closed path by a single CSS animation from stroke-dashoffset 0 to -1 over 2400ms linear — a constant arc-length rate, one full traversal per 2.4s. It is never a swap to a different widget: same rosette, same ghost, same pen. A ref records where the window's head is each frame, so when a real value later arrives the component seeds the inline dashoffset at that head with the transition suppressed for one write (forced reflow), re-enables the transition, then writes the target — the trace converges to the true value from wherever the sweep happened to be rather than snapping back to empty. ARIA: the wrapper is role="progressbar" with aria-valuemin=0, aria-valuemax=100 and aria-label from the `label` prop; aria-valuenow is set only in determinate mode and OMITTED ENTIRELY while indeterminate, which is the correct indeterminate signal rather than a fake 0. A Geist Mono tabular-nums readout sits beside the rosette showing the rounded percent, or an em-dash rule while indeterminate; it is aria-hidden because the progressbar role already carries the value. Display-only: no pointer or keyboard interaction, no focusable control, nothing to focus-ring. TOKENS: every stroke and fill is a presentation attribute reading a CSS custom property directly (var(--foreground) for both hairlines, var(--accent) for the pen), so there is no getComputedStyle and no MutationObserver — the cascade handles a theme flip for free, and both themes are correct by construction. prefers-reduced-motion: reduce disables both the 260ms transition and the 2400ms sweep animation in a media query, and the script path checks matchMedia too — determinate progress snaps straight to its new dashoffset with the pen placed once, and the indeterminate state renders a static 0.18 dash window at a fixed offset with the pen parked at its head, so the resting frame is still a legible partially-inked rosette rather than a frozen empty one. Props: value (0-100, omit for indeterminate), size (px, default 160), label (default 'Loading'), R (default 5), r (default 3), d (default 2.1), className.

Tags
loaderprogressprogressbarsvgspirographhypotrochoiddeterminateindeterminateaccessibility