Skip to main content

ns-ui

Decatron Step Ring

Circular step/cyclic-position indicator modelled on a decatron cold-cathode counting tube: a glow spot steps around ten radial stations, stretching onto an interleaved guide electrode before snapping fully onto the next cathode, free-running as an unbounded decade counter.

Use when a circular step/cyclic-position display — onboarding progress, a multi-step form's current position, or a rotating counter readout — where the transfer between stations should visibly depart and arrive rather than blink; the mechanism is a two-phase guide-then-main cathode transfer borrowed from decatron counting tubes, so it reads as physical relocation, not an eased sweep. Pick progress-hatch instead for a bounded LINEAR determinate progress meter with a numeric readout rather than a cyclic ring; pick loader-spring-bars instead for pure ambient/indefinite loading with no positional identity to track.

Install

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

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

// ---------------------------------------------------------------------------
// DecatronStepRing — a circular step/cyclic-position indicator modelled on a
// decatron cold-cathode counting tube: ten main-cathode stations arranged
// radially, with a guide-electrode station interleaved between each pair.
// Real decatrons never jump the glow discharge straight from one cathode to
// the next — a guide electrode between them is pulsed first and the glow
// visibly stretches partway onto it, then the next main cathode's pulse
// completes the transfer and the glow snaps fully home. That two-phase
// "stretch, then snap" is the entire mechanic; everything below exists to
// keep it legible at one station per 1.4s.
//
// Geometry is fixed inside a 200x200 viewBox (mainAngle(k) = -90 + k*36deg,
// guideAngle(k) = mainAngle(k) + 18deg, ring radius = 38% of the viewBox),
// so `preserveAspectRatio="xMidYMid meet"` alone derives the ring from
// whichever dimension of the container is smaller — no ResizeObserver
// needed for layout, only a zero-size guard before the paint loop starts.
//
// All ink is CSS custom properties read natively by the SVG (fill/stop-color
// set via `var(--token)` in `style`, exactly the pattern auxin-canal uses)
// so theme swaps repaint for free with no getComputedStyle round trip and no
// pre-paint race. Unlit nodes are `color-mix(in oklch, var(--foreground)
// 38%, var(--background))` rather than a fixed low-opacity foreground — a
// blend anchored to both theme tokens instead of one, so the ring reads as
// ten dots at a consistent contrast in both themes rather than nearly
// vanishing in light theme the way a flat low-alpha dot would.
//
// Only opacity/radius on the lit elements move per frame (direct attribute
// writes on refs, no React state on the hot path): a small litCore + halo
// per main cathode, and one stretched halo ellipse per guide gap, spanning
// the two main cathodes it sits between.
// ---------------------------------------------------------------------------

const STATIONS = 10;
const STEP_MS = 1400;
const GUIDE_FRAC = 0.3; // first 30% of a step is the guide-transfer phase
const NOISE_HZ = 2; // idle plasma noise on the lit halo
const NOISE_AMP = 0.08; // +-8% halo radius

const VB = 200;
const CENTER = VB / 2;
const RING_R = VB * 0.38;
const MAIN_DOT_R = RING_R * 0.04;
const GUIDE_DOT_R = MAIN_DOT_R * 0.85;
const LIT_CORE_R = MAIN_DOT_R * 1.2;
const HALO_R = RING_R * 0.14;

interface Pt {
  x: number;
  y: number;
}

// Rounded to a fixed precision so server (Node/V8) and client (browser JS
// engine) trig results can never differ at the last float bit and produce
// a hydration-mismatching attribute string — geometry is otherwise static.
function round4(n: number): number {
  return Math.round(n * 10000) / 10000;
}

function polar(angleDeg: number, r: number): Pt {
  const rad = (angleDeg * Math.PI) / 180;
  return { x: round4(CENTER + r * Math.cos(rad)), y: round4(CENTER + r * Math.sin(rad)) };
}

interface StretchEllipse {
  cx: number;
  cy: number;
  rx: number;
  ry: number;
  rotate: number;
}

interface RingGeometry {
  mainPos: Pt[];
  guidePos: Pt[];
  stretch: StretchEllipse[];
}

function buildGeometry(): RingGeometry {
  const mainAngle = (k: number) => -90 + k * (360 / STATIONS);
  const mainPos = Array.from({ length: STATIONS }, (_, k) => polar(mainAngle(k), RING_R));
  const guidePos = Array.from({ length: STATIONS }, (_, k) =>
    polar(mainAngle(k) + 360 / STATIONS / 2, RING_R)
  );
  const stretch = Array.from({ length: STATIONS }, (_, k) => {
    const a = mainPos[k]!;
    const b = mainPos[(k + 1) % STATIONS]!;
    const center = guidePos[k]!;
    const dx = b.x - a.x;
    const dy = b.y - a.y;
    const dist = Math.hypot(dx, dy);
    const rotate = round4((Math.atan2(dy, dx) * 180) / Math.PI);
    return {
      cx: center.x,
      cy: center.y,
      rx: round4(dist / 2 + HALO_R * 0.3),
      ry: round4(HALO_R * 0.5),
      rotate,
    };
  });
  return { mainPos, guidePos, stretch };
}

export interface DecatronStepRingProps {
  /** external station index (0-based) to home on; omit for a free-running,
   * self-advancing counter (the showpiece default). */
  activeStep?: number;
  /** accessible label announced when `activeStep` is provided */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function DecatronStepRing({ activeStep, label = "Step", className = "" }: DecatronStepRingProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const svgRef = useRef<SVGSVGElement>(null);
  const litCoreRefs = useRef<(SVGCircleElement | null)[]>([]);
  const haloRefs = useRef<(SVGCircleElement | null)[]>([]);
  const guideRefs = useRef<(SVGEllipseElement | null)[]>([]);

  const geometry = useMemo(buildGeometry, []);
  const controlled = activeStep !== undefined;
  const activeStepRef = useRef(activeStep);
  activeStepRef.current = activeStep;

  useEffect(() => {
    const root = rootRef.current;
    const svg = svgRef.current;
    if (!root || !svg) return;

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

    let disposed = false;
    let visible = true;
    let raf = 0;
    let start = 0;

    // controlled-mode hop state: home is the settled station, a pending
    // target advances one hop per STEP_MS along the shortest direction
    // rather than teleporting.
    let home = ((activeStepRef.current ?? 0) % STATIONS + STATIONS) % STATIONS;
    let dir = 1;
    let hopsRemaining = 0;
    let hopStartElapsed = 0;

    const queueTo = (target: number, elapsed: number) => {
      const t = ((target % STATIONS) + STATIONS) % STATIONS;
      const diff = (t - home + STATIONS) % STATIONS;
      if (diff === 0) {
        hopsRemaining = 0;
        return;
      }
      dir = diff <= STATIONS / 2 ? 1 : -1;
      hopsRemaining = dir === 1 ? diff : STATIONS - diff;
      hopStartElapsed = elapsed;
    };

    const setNode = (refs: (SVGGraphicsElement | null)[], i: number, opacity: number, r?: number) => {
      const el = refs[i];
      if (!el) return;
      el.setAttribute("opacity", opacity.toFixed(3));
      if (r !== undefined) el.setAttribute("r", r.toFixed(2));
    };

    const render = (current: number, next: number, progress: number, noise: number) => {
      const mainBrightness = new Array<number>(STATIONS).fill(0);
      const guideBrightness = new Array<number>(STATIONS).fill(0);

      if (progress <= 0) {
        mainBrightness[current] = 1;
      } else if (progress < GUIDE_FRAC) {
        mainBrightness[current] = 1;
        guideBrightness[current] = (progress / GUIDE_FRAC) * 0.7;
      } else {
        const t3 = (progress - GUIDE_FRAC) / (1 - GUIDE_FRAC);
        mainBrightness[current] = 1 - t3;
        mainBrightness[next] = t3;
        guideBrightness[current] = 0.7 * (1 - t3);
      }

      for (let k = 0; k < STATIONS; k++) {
        const b = mainBrightness[k] ?? 0;
        setNode(litCoreRefs.current, k, b);
        const haloR = b > 0.02 ? HALO_R * noise : HALO_R;
        setNode(haloRefs.current, k, b * 0.9, haloR);
        const g = guideBrightness[k] ?? 0;
        setNode(guideRefs.current, k, (g / 0.7) * 0.85);
      }
    };

    const frame = (elapsed: number) => {
      const noise = 1 + NOISE_AMP * Math.sin(elapsed * 0.001 * 2 * Math.PI * NOISE_HZ);

      if (!controlled) {
        const totalSteps = elapsed / STEP_MS;
        const stepIndex = Math.floor(totalSteps);
        const progress = totalSteps - stepIndex;
        const current = ((stepIndex % STATIONS) + STATIONS) % STATIONS;
        const next = (current + 1) % STATIONS;
        render(current, next, progress, noise);
        return;
      }

      if (hopsRemaining <= 0) {
        render(home, home, 0, noise);
        return;
      }
      const local = elapsed - hopStartElapsed;
      let progress = Math.min(1, local / STEP_MS);
      const next = (home + dir + STATIONS) % STATIONS;
      if (progress >= 1) {
        home = next;
        hopsRemaining -= 1;
        hopStartElapsed = elapsed;
        progress = 0;
      }
      render(home, dir === 1 ? next : (home - 1 + STATIONS) % STATIONS, progress, noise);
    };

    const loop = (now: number) => {
      if (start === 0) start = now;
      frame(now - start);
      if (!disposed && visible) raf = requestAnimationFrame(loop);
      else raf = 0;
    };

    if (reduced) {
      // deliberately non-t0 frame: 15% into a step, mid guide-phase stretch,
      // so both the ring layout and the two-phase transfer read at a glance.
      render(0, 1, 0.15, 1);
    } else {
      raf = requestAnimationFrame(loop);
    }

    const io = new IntersectionObserver((entries) => {
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !raf && !reduced && !disposed) {
        start = 0;
        raf = requestAnimationFrame(loop);
      }
    });
    io.observe(root);

    // controlled-mode prop watcher: re-queue toward the latest activeStep
    // whenever it changes, without restarting the whole rAF loop.
    let lastSeenTarget = activeStepRef.current;
    const watch = () => {
      if (disposed) return;
      if (controlled && activeStepRef.current !== lastSeenTarget) {
        lastSeenTarget = activeStepRef.current;
        queueTo(activeStepRef.current ?? 0, performance.now() - start);
      }
    };
    const watchId = controlled ? window.setInterval(watch, 100) : 0;

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      io.disconnect();
      if (watchId) window.clearInterval(watchId);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [controlled]);

  const dimFill = "color-mix(in oklch, var(--foreground) 38%, var(--background))";

  return (
    <div
      ref={rootRef}
      role={controlled ? "progressbar" : undefined}
      aria-label={controlled ? label : undefined}
      aria-valuemin={controlled ? 1 : undefined}
      aria-valuemax={controlled ? STATIONS : undefined}
      aria-valuenow={controlled ? ((activeStep ?? 0) % STATIONS) + 1 : undefined}
      className={`relative aspect-square w-full ${className}`}
    >
      <svg
        ref={svgRef}
        viewBox={`0 0 ${VB} ${VB}`}
        preserveAspectRatio="xMidYMid meet"
        aria-hidden="true"
        focusable="false"
        className="h-full w-full"
      >
        <defs>
          <radialGradient id="decatron-glow">
            <stop offset="0%" style={{ stopColor: "var(--foreground)", stopOpacity: 0.9 }} />
            <stop offset="55%" style={{ stopColor: "var(--foreground)", stopOpacity: 0.35 }} />
            <stop offset="100%" style={{ stopColor: "var(--foreground)", stopOpacity: 0 }} />
          </radialGradient>
        </defs>

        {/* rest-state ring: twenty dim dots, always drawn, never touched by rAF */}
        {geometry.mainPos.map((p, i) => (
          <circle key={`m-dim-${i}`} cx={p.x} cy={p.y} r={MAIN_DOT_R} style={{ fill: dimFill }} />
        ))}
        {geometry.guidePos.map((p, i) => (
          <circle key={`g-dim-${i}`} cx={p.x} cy={p.y} r={GUIDE_DOT_R} style={{ fill: dimFill }} />
        ))}

        {/* guide-phase stretch: one soft ellipse per gap, opacity-driven */}
        {geometry.stretch.map((s, i) => (
          <ellipse
            key={`g-halo-${i}`}
            ref={(el) => {
              guideRefs.current[i] = el;
            }}
            cx={s.cx}
            cy={s.cy}
            rx={s.rx}
            ry={s.ry}
            transform={`rotate(${s.rotate} ${s.cx} ${s.cy})`}
            opacity={0}
            fill="url(#decatron-glow)"
          />
        ))}

        {/* main-phase halo + lit core, one pair per cathode */}
        {geometry.mainPos.map((p, i) => (
          <circle
            key={`m-halo-${i}`}
            ref={(el) => {
              haloRefs.current[i] = el;
            }}
            cx={p.x}
            cy={p.y}
            r={HALO_R}
            opacity={0}
            fill="url(#decatron-glow)"
          />
        ))}
        {geometry.mainPos.map((p, i) => (
          <circle
            key={`m-core-${i}`}
            ref={(el) => {
              litCoreRefs.current[i] = el;
            }}
            cx={p.x}
            cy={p.y}
            r={LIT_CORE_R}
            opacity={0}
            style={{ fill: "var(--foreground)" }}
          />
        ))}
      </svg>
    </div>
  );
}
Build spec

A circular step indicator built as an SVG decatron: twenty nodes fixed in a 200x200 viewBox arranged in a ring at 38% of the viewBox radius — ten main-cathode stations at 36-degree intervals (mainAngle(k) = -90 + k*36, so station 0 sits at the top) and ten guide-electrode stations interleaved at the angular midpoint of each gap (mainAngle(k)+18). `preserveAspectRatio="xMidYMid meet"` alone derives the ring from the container's SMALLER dimension, so no ResizeObserver is needed for layout. All twenty rest-state dots are drawn once, statically, as `color-mix(in oklch, var(--foreground) 38%, var(--background))` circles (main dot radius 4% of ring radius, guide dots 85% of that) — a token-anchored blend rather than a flat low-opacity foreground, chosen because a fixed-alpha dim dot reads fine in dark theme and nearly vanishes in light theme; mixing against both `--foreground` and `--background` keeps contrast consistent across themes. On top of the rest ring: one halo+lit-core circle pair per main cathode (halo radius 14% of ring radius, using a `radialGradient` whose stops are `var(--foreground)` at decreasing stop-opacity, so it needs no JS colour read at all and repaints for free on a theme class flip) and one stretched `<ellipse>` per gap, geometry precomputed once (centered on the guide position, rx = half the chord between its two flanking main cathodes plus a small overshoot, ry = half the halo radius, rotated to align with that chord) — this ellipse is the guide-phase 'stretch' visual, connecting the two main-cathode positions it sits between. Motion: a single rAF loop (no React state on the hot path, all writes are direct `setAttribute("opacity"|"r", ...)` calls on refs) drives a 1400ms step cycle: the first 30% (420ms) is the guide phase, where the current main cathode holds full brightness while the guide ellipse between it and the next station ramps opacity 0 to a 0.7-brightness ceiling; the remaining 70% (980ms) is the main phase, where the guide fades 0.7 to 0 while the current cathode's brightness falls 1 to 0 and the next cathode's rises 0 to 1 in lockstep, so departure and arrival are simultaneous and visible, never a blink. Whichever main cathode has nonzero brightness gets its halo radius multiplied by `1 + 0.08*sin(elapsed*0.001*2*PI*2)` every frame (~2Hz, +-8%) to read as gas-discharge noise rather than a static LED, applied continuously regardless of step phase so the component is visibly alive at any timestamp. With no `activeStep` prop the component free-runs forever from elapsed real time (`stepIndex = floor(elapsedMs/1400) % 10`, `progress = elapsedMs/1400 mod 1`), completing one 10-station lap every 14s, unbounded — this is the default showpiece demo and has no external driver. When an `activeStep` prop IS supplied, the component switches to a controlled hop queue: on every prop change it computes the shortest direction around the 10-station ring from the currently settled station to the new target (diff <= 5 steps clockwise, else counter-clockwise), queues that many discrete 1400ms hops (each hop running the identical guide-then-main envelope described above, chained back-to-back with no gap), and holds steady with only the idle plasma noise once the target is reached — it never teleports. `role="progressbar"` with `aria-valuemin=1 aria-valuemax=10 aria-valuenow` is added only in controlled mode; the free-run default is `aria-hidden` decorative since it carries no external state to announce. `autoplay: none` because the ring animates on its own internal clock with nothing for the site's synthetic-input driver to trigger. Under `prefers-reduced-motion`, the rAF loop never starts; a single frame is rendered once at `progress=0.15` on the station-0-to-station-1 gap — 15% into a step, mid guide-phase stretch — chosen because it is the one frame that shows both the full ring layout and the two-phase transfer mechanic simultaneously, rather than a settled single-dot frame with nothing else lit. Cleanup cancels the rAF, disconnects the IntersectionObserver (which pauses the loop off-screen and resumes it with a fresh time origin), and clears the controlled-mode prop-watch interval. Zero dependencies, SVG+CSS only, no canvas, every colour a token read natively via CSS custom properties (`--foreground`, `--background`) with zero literals and zero getComputedStyle calls, since SVG `style` attributes resolve `var()` through the normal CSS cascade with no pre-paint race to guard against.

Props

PropTypeDefaultDescription
activeStep?numberexternal station index (0-based) to home on; omit for a free-running, self-advancing counter (the showpiece default).
label?string"Step"accessible label announced when `activeStep` is provided
className?stringextra classes merged onto the rendered root element