Skip to main content

ns-ui

Loader Spirograph Trace

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.

Usage

registry/core/loader-spirograph-trace/demo.tsx
"use client";

import { useEffect, useState } from "react";
import { SpiroTrace } from "./component";

// The resting frame must already read as "a progress trace on a spirograph":
// the hero starts part-inked at REST_VALUE, so the ghost route and the inked
// fraction are both on screen before anything moves.
const REST_VALUE = 62;

export default function SpiroTraceDemo() {
  const [value, setValue] = useState(REST_VALUE);

  // a slow index build: uneven arrivals, a hold at 100, then the next shard
  useEffect(() => {
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
    let holdUntil = 0;
    let v = REST_VALUE;
    let t = 0;
    const step = () => {
      const now = performance.now();
      if (v >= 100) {
        if (holdUntil === 0) holdUntil = now + 2600;
        else if (now >= holdUntil) {
          v = REST_VALUE;
          holdUntil = 0;
          setValue(REST_VALUE);
        }
      } else {
        v = Math.min(100, v + 6 + Math.random() * 11);
        setValue(v);
      }
      t = window.setTimeout(step, 420 + Math.random() * 460);
    };
    t = window.setTimeout(step, 1400);
    return () => window.clearTimeout(t);
  }, []);

  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 / loader-spirograph-trace
      </p>

      <div
        data-spiro-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"
      >
        <SpiroTrace size={200} value={value} label="Building search index" />
        <p className="text-sm text-foreground">Building search index</p>
        <p className="font-mono text-[11px] text-ns-muted">
          inked arc length = progress, on the full rosette
        </p>
      </div>

      <div className="flex flex-wrap items-center justify-center gap-10">
        <div className="flex flex-col items-center gap-3">
          <SpiroTrace size={112} label="Reticulating shard index" />
          <p className="font-mono text-[11px] text-ns-muted">no total yet — arc-length sweep</p>
        </div>

        <div className="flex flex-col items-center gap-3">
          <SpiroTrace size={112} value={100} label="Shaders compiled" />
          <p className="font-mono text-[11px] text-ns-muted">complete — the whole curve inked</p>
        </div>
      </div>
    </main>
  );
}

Component

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;
  /** extra classes merged onto the rendered root element */
  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(--ns-accent)" />
      </svg>

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

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

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.

Install

npx shadcn add https://design.helpmarq.com/r/loader-spirograph-trace.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 <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(--ns-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(--ns-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.

Props

PropTypeDefaultDescription
value?number—progress 0-100. Leave undefined for indeterminate (a travelling sweep).
size?number160glyph size in px.
label?string"Loading"accessible name for the progressbar.
R?number5fixed radius of the spirograph ring.
r?number3rolling radius. gcd(R, r) = 1 keeps the rosette a single closed curve.
d?number2.1pen offset from the rolling circle's centre.
className?string—extra classes merged onto the rendered root element