ns-ui
Lug Cage Tally
An ambient loading glyph shaped as five pin wheels spinning at mutually-prime rates past a fixed read mark, each engaged pin brightening in a brief flash and nudging a 24-notch tally bar forward one segment, the bar rolling its window open-ended rather than ever resetting to empty.
Use when an indeterminate wait where a single glyph should look like several independent processes converging on one shared counter rather than one uniform pulse — five wheels at deliberately mismatched, mutually-prime rotation rates so their combined pattern stays visibly decorrelated well past a minute of viewing; pick loader-spring-bars or loader-die-tumble instead when the wait is short and a single simple rhythm reads better, or loader-pendulum-sync when the row should read as one phased wave rather than several unrelated sources feeding one total.
Install
npx shadcn add https://design.helpmarq.com /r/lug-cage-tally.jsonSource
registry/core/lug-cage-tally/component.tsx"use client";
import { useEffect, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// LugCageTally — an ambient loading glyph shaped as a row of five pin
// wheels spinning past a fixed read position, each engaged pin nudging a
// segmented tally bar forward one notch. Five wheels, mutually-prime full
// rotation periods (7s, 6s, 5s, 4s, 3.5s), each ringed with 8 pins in a
// fixed active/inactive pattern set once at mount. Every 45deg of rotation
// carries the next pin past the fixed 12-o'clock read mark; if that pin is
// active, its rim mark brightens in a 90ms luminance flash and the tally
// bar's next notch fills. Because the five periods share no small common
// factor their combined phase relationship doesn't repeat for well over a
// minute, so the wheels keep looking freshly decorrelated on any viewing.
// Pure SVG + direct-DOM rAF writes (refs only, no React state on the hot
// path); geometry is recomputed only on resize.
// ---------------------------------------------------------------------------
const WHEEL_COUNT = 5;
const PINS_PER_WHEEL = 8;
const PIN_STEP_DEG = 360 / PINS_PER_WHEEL;
// deg/s for each wheel — periods 7s, 6s, 5s, 4s, 3.5s, chosen to share no
// small common factor so the combined pattern reads as freshly decorrelated
const RATES_DEG_S = [51.42857142857143, 60, 72, 90, 102.85714285714286];
const FLASH_MS = 90;
const TALLY_LENGTH = 24;
const NOTCH_FILL_MS = 140;
const NOTCH_FADE_MS = 200;
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** exactly half the pins active, shuffled per wheel — set once, never
* touched again, so a wheel's pattern never gives itself away except
* through the read-event flash. */
function buildPattern(rand: () => number): boolean[] {
const arr = [true, true, true, true, false, false, false, false];
for (let i = arr.length - 1; i > 0; i--) {
const j = Math.floor(rand() * (i + 1));
const tmp = arr[i]!;
arr[i] = arr[j]!;
arr[j] = tmp;
}
return arr;
}
/** which pin index sits at the fixed 12-o'clock read mark after N pin-steps
* of rotation, given pin 0 starts there at t=0 — the wheel visits pins in
* descending index order as it turns, so this walks backward mod 8. */
function pinAtReadMark(stepCount: number): number {
return (PINS_PER_WHEEL - (stepCount % PINS_PER_WHEEL)) % PINS_PER_WHEEL;
}
interface Layout {
w: number;
h: number;
wheelR: number;
wheelCx: number[];
wheelCy: number;
tallyX: number;
tallyY: number;
tallyW: number;
tallyH: number;
notchW: number;
notchGap: number;
}
function computeLayout(w: number, h: number): Layout {
const smaller = Math.max(1, Math.min(w, h));
const d = smaller / 6.5; // wheel diameter
const wheelR = d / 2;
const tallyH = d * 0.3;
const gapH = d * 0.55;
const rowW = WHEEL_COUNT * d + (WHEEL_COUNT - 1) * gapH;
const vGap = d * 0.4;
const contentH = d + vGap + tallyH;
const topY = Math.max(0, (h - contentH) / 2);
const wheelCy = topY + wheelR;
const startX = Math.max(0, (w - rowW) / 2) + wheelR;
const wheelCx = Array.from({ length: WHEEL_COUNT }, (_, i) => startX + i * (d + gapH));
const tallyX = Math.max(0, (w - rowW) / 2);
const tallyY = topY + d + vGap;
const notchGap = tallyH * 0.22;
const notchW = (rowW - notchGap * (TALLY_LENGTH - 1)) / TALLY_LENGTH;
return {
w,
h,
wheelR,
wheelCx,
wheelCy,
tallyX,
tallyY,
tallyW: rowW,
tallyH,
notchW: Math.max(1, notchW),
notchGap,
};
}
export interface LugCageTallyProps {
/** text announced via the component's own aria-live region */
label?: string;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function LugCageTally({ label = "Loading", className = "" }: LugCageTallyProps) {
const rootRef = useRef<HTMLDivElement>(null);
const svgRef = useRef<SVGSVGElement>(null);
const wheelGroupRefs = useRef<(SVGGElement | null)[]>([]);
const pinRefs = useRef<(SVGLineElement | null)[][]>(
Array.from({ length: WHEEL_COUNT }, () => [])
);
const notchRefs = useRef<(SVGRectElement | null)[]>([]);
const [dims, setDims] = useState({ w: 0, h: 0 });
const layout = useMemo(() => computeLayout(dims.w, dims.h), [dims.w, dims.h]);
// container sizing — smaller dimension drives every other measurement
useEffect(() => {
const root = rootRef.current;
if (!root) return;
const ro = new ResizeObserver((entries) => {
const entry = entries[0];
if (!entry) return;
const box = entry.contentRect;
setDims({ w: box.width, h: box.height });
});
ro.observe(root);
return () => ro.disconnect();
}, []);
// patterns fixed once per mount, independent of layout/resize
const patternsRef = useRef<boolean[][] | null>(null);
if (!patternsRef.current) {
const rand = mulberry32(Date.now() ^ 0x9e3779b9);
patternsRef.current = Array.from({ length: WHEEL_COUNT }, () => buildPattern(rand));
}
useEffect(() => {
const root = rootRef.current;
if (!root || layout.w <= 0 || layout.h <= 0) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const patterns = patternsRef.current!;
let disposed = false;
let visible = true;
let raf = 0;
let startTime = 0;
const stepCounts = new Array(WHEEL_COUNT).fill(0);
const flashTimers: (ReturnType<typeof setTimeout> | undefined)[][] = Array.from(
{ length: WHEEL_COUNT },
() => []
);
let rollTimer: ReturnType<typeof setTimeout> | undefined;
const tallyFilled: boolean[] = new Array(TALLY_LENGTH).fill(false);
const setNotchStyle = (el: SVGRectElement | null, filled: boolean, withTransition: boolean) => {
if (!el) return;
el.style.transition = withTransition
? `fill-opacity ${NOTCH_FILL_MS}ms ease-out`
: "none";
el.style.fillOpacity = filled ? "0.8" : "0";
};
const advanceTally = () => {
const filledCount = tallyFilled.filter(Boolean).length;
if (filledCount < TALLY_LENGTH) {
const idx = tallyFilled.findIndex((v) => !v);
if (idx < 0) return;
tallyFilled[idx] = true;
setNotchStyle(notchRefs.current[idx] ?? null, true, true);
return;
}
// rolling window: fade the leftmost notch, then re-index the strip
// and fill the rightmost — no hard reset, the bar never empties
const first = notchRefs.current[0];
if (first) {
first.style.transition = `fill-opacity ${NOTCH_FADE_MS}ms ease`;
first.style.fillOpacity = "0";
}
if (rollTimer) clearTimeout(rollTimer);
rollTimer = setTimeout(() => {
if (disposed) return;
for (let i = 0; i < TALLY_LENGTH - 1; i++) tallyFilled[i] = tallyFilled[i + 1] ?? true;
tallyFilled[TALLY_LENGTH - 1] = false;
for (let i = 0; i < TALLY_LENGTH - 1; i++) {
setNotchStyle(notchRefs.current[i] ?? null, true, false);
}
tallyFilled[TALLY_LENGTH - 1] = true;
setNotchStyle(notchRefs.current[TALLY_LENGTH - 1] ?? null, true, true);
}, NOTCH_FADE_MS);
};
const flashPin = (wheelIdx: number, pinIdx: number) => {
const el = pinRefs.current[wheelIdx]?.[pinIdx];
if (!el) return;
const pending = flashTimers[wheelIdx]?.[pinIdx];
if (pending) clearTimeout(pending);
el.style.transition = "none";
el.style.opacity = "1";
requestAnimationFrame(() => {
if (disposed) return;
el.style.transition = `opacity ${FLASH_MS}ms ease-out`;
el.style.opacity = "0.85";
});
const arr = flashTimers[wheelIdx];
if (arr) {
arr[pinIdx] = setTimeout(() => {
if (el) el.style.opacity = "0.85";
}, FLASH_MS + 20);
}
};
const applyFrame = (elapsedSec: number) => {
for (let i = 0; i < WHEEL_COUNT; i++) {
const rate = RATES_DEG_S[i] ?? 0;
const angle = (rate * elapsedSec) % 360;
const g = wheelGroupRefs.current[i];
if (g) g.setAttribute("transform", `rotate(${angle.toFixed(2)} ${layout.wheelCx[i] ?? 0} ${layout.wheelCy})`);
const cumSteps = Math.floor((rate * elapsedSec) / PIN_STEP_DEG);
let prev = stepCounts[i] ?? 0;
// guard against a huge dt (tab backgrounded) firing hundreds of
// catch-up reads at once — only the most recent read is meaningful
if (cumSteps - prev > PINS_PER_WHEEL) prev = cumSteps - PINS_PER_WHEEL;
while (prev < cumSteps) {
prev++;
const pinIdx = pinAtReadMark(prev);
if (patterns[i]?.[pinIdx]) {
flashPin(i, pinIdx);
advanceTally();
}
}
stepCounts[i] = cumSteps;
}
};
const loop = (now: number) => {
raf = 0;
if (!visible) return;
if (startTime === 0) startTime = now;
applyFrame((now - startTime) / 1000);
raf = requestAnimationFrame(loop);
};
if (reduced) {
// STATIC_PHASE: five wheels frozen at distinct phases, at least two
// pin marks caught mid-fade, tally sitting at ~60% — the frame that
// best shows "mechanism mid-tally" rather than a clean idle state
const freezeAngles = [22, 168, 301, 74, 249];
for (let i = 0; i < WHEEL_COUNT; i++) {
const g = wheelGroupRefs.current[i];
if (g) g.setAttribute("transform", `rotate(${freezeAngles[i]} ${layout.wheelCx[i] ?? 0} ${layout.wheelCy})`);
}
const midFadeWheels = [1, 3];
for (let i = 0; i < WHEEL_COUNT; i++) {
for (let p = 0; p < PINS_PER_WHEEL; p++) {
const el = pinRefs.current[i]?.[p];
if (!el) continue;
el.style.transition = "none";
el.style.opacity = midFadeWheels.includes(i) && p === 0 ? "0.93" : "0.85";
}
}
const filledUpTo = Math.round(TALLY_LENGTH * 0.6);
for (let i = 0; i < TALLY_LENGTH; i++) {
setNotchStyle(notchRefs.current[i] ?? null, i < filledUpTo, false);
}
return () => {
disposed = true;
};
}
raf = requestAnimationFrame(loop);
const io = new IntersectionObserver((entries) => {
visible = entries[0]?.isIntersecting ?? true;
if (visible && !raf) raf = requestAnimationFrame(loop);
});
io.observe(root);
return () => {
disposed = true;
cancelAnimationFrame(raf);
raf = 0;
io.disconnect();
if (rollTimer) clearTimeout(rollTimer);
for (const wheel of flashTimers) {
for (const t of wheel) if (t) clearTimeout(t);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [layout.w, layout.h]);
return (
<div
ref={rootRef}
role="status"
aria-live="polite"
data-lug-cage-tally
className={`relative flex h-full w-full items-center justify-center ${className}`}
>
<svg
ref={svgRef}
aria-hidden="true"
focusable="false"
className="h-full w-full"
viewBox={`0 0 ${Math.max(1, layout.w)} ${Math.max(1, layout.h)}`}
>
{layout.wheelCx.map((cx, i) => (
<g key={i}>
{/* fixed 12-o'clock read mark — the highest-contrast reference
point, does not rotate */}
<line
x1={cx}
y1={layout.wheelCy - layout.wheelR - layout.wheelR * 0.32}
x2={cx}
y2={layout.wheelCy - layout.wheelR - layout.wheelR * 0.08}
stroke="var(--foreground)"
strokeWidth={Math.max(1, layout.wheelR * 0.09)}
strokeLinecap="round"
/>
<g
ref={(el) => {
wheelGroupRefs.current[i] = el;
}}
>
<circle
cx={cx}
cy={layout.wheelCy}
r={layout.wheelR}
fill="none"
stroke="var(--foreground)"
strokeOpacity={0.55}
strokeWidth={Math.max(1, layout.wheelR * 0.06)}
/>
{Array.from({ length: PINS_PER_WHEEL }, (_, p) => {
const angle = (p * PIN_STEP_DEG - 90) * (Math.PI / 180);
const rOuter = layout.wheelR;
const rInner = layout.wheelR * 0.78;
const x1 = cx + Math.cos(angle) * rOuter;
const y1 = layout.wheelCy + Math.sin(angle) * rOuter;
const x2 = cx + Math.cos(angle) * rInner;
const y2 = layout.wheelCy + Math.sin(angle) * rInner;
return (
<line
key={p}
ref={(el) => {
const arr = pinRefs.current[i];
if (arr) arr[p] = el;
}}
x1={x1}
y1={y1}
x2={x2}
y2={y2}
stroke="var(--foreground)"
strokeOpacity={0.85}
strokeWidth={Math.max(1, layout.wheelR * 0.1)}
strokeLinecap="round"
/>
);
})}
</g>
</g>
))}
{Array.from({ length: TALLY_LENGTH }, (_, i) => {
const x = layout.tallyX + i * (layout.notchW + layout.notchGap);
return (
<g key={i}>
<rect
x={x}
y={layout.tallyY}
width={layout.notchW}
height={layout.tallyH}
fill="none"
stroke="var(--border)"
strokeWidth={1}
/>
<rect
ref={(el) => {
notchRefs.current[i] = el;
}}
x={x}
y={layout.tallyY}
width={layout.notchW}
height={layout.tallyH}
fill="var(--foreground)"
fillOpacity={0}
/>
</g>
);
})}
</svg>
<span className="sr-only">{label}</span>
</div>
);
}
Build spec
A card-scale ambient loading glyph rendered as pure SVG: five small pin wheels in a horizontal row, each an 8-pin rim (fixed active/inactive pattern, exactly four of eight pins active, shuffled once per wheel at mount via a seeded mulberry32 PRNG and never touched again) spinning at its own constant deg/s rate (51.4286, 60, 72, 90, 102.8571 — full-rotation periods of 7s, 6s, 5s, 4s, 3.5s chosen so no two periods share a small common factor, keeping the five wheels' combined phase relationship visibly fresh for well over a minute). A fixed, non-rotating tick mark sits above each wheel at its 12-o'clock read position, drawn at full var(--foreground) opacity as the one static high-contrast reference point; every 45deg of a wheel's rotation carries its next pin past that mark. The animation loop (rAF, direct SVG attribute/style writes only, zero React state on the hot path) tracks each wheel's cumulative pin-step count from elapsed time; whenever that count increments, it looks up which pin index just crossed the read mark (wheels visit pins in descending index order as they turn) and, if that pin is active in the wheel's fixed pattern, triggers two effects: a 90ms luminance-only flash on that pin's SVG line (opacity jumps to 1 with transitions disabled, then eases back to its resting 0.85 over 90ms) and one advance of a shared 24-notch tally bar below the wheels. The tally fills its notches left to right (each a 140ms ease-out fill-opacity transition to var(--foreground) at 0.8) until full, after which every further tick rolls the window: the leftmost notch fades to 0 opacity over 200ms, then the whole strip is instantly re-indexed one slot left and the rightmost notch begins its own 140ms fill — the bar never hard-resets or flashes back to empty. Geometry is derived once per resize from the container's smaller dimension via a ResizeObserver (wheel diameter = smaller/6.5, tally bar height = wheel diameter x0.3, everything else — gaps, pin length, notch width — scaled off that same base unit) and applied through a memoized layout object; only the resize-driven layout touches React re-render, every per-frame write goes straight to refs. An IntersectionObserver pauses the rAF loop while off-screen and resumes it without resetting the elapsed clock, so wheel phase never jumps. `prefers-reduced-motion` skips the rAF loop entirely and instead freezes five wheels at distinct static rotation angles with two of the ten flash states caught mid-decay (opacity 0.93 instead of resting 0.85 or full 1) and the tally bar pinned at 60% fill — the frame chosen to read as 'mechanism mid-tally', never a clean idle state. Every colour is a token read directly in SVG attributes/inline styles (var(--foreground), var(--border)) with zero literals and zero getComputedStyle reads, since nothing here computes or interpolates a numeric colour value; --border only ever outlines an empty notch, never fills one, and there is no --ns-accent anywhere — this is ambient chrome, not interaction chrome. The whole glyph is a single `role="status" aria-live="polite"` element with the SVG marked `aria-hidden`; a visually hidden `label` prop (default 'Loading') is the only thing announced, and it renders no interactive controls. Props: label, className. Zero dependencies, no canvas, no WebGL, cleans up rAF/IntersectionObserver/setTimeout handles on unmount and on every resize-triggered effect re-run.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| label? | string | "Loading" | text announced via the component's own aria-live region |
| className? | string | — | extra classes merged onto the rendered root element |