Skip to main content

ns-ui

Remontoire Rewind

A sync/upload progress strip driven by a constant-force remontoire: a coiled spring glyph slowly winds tight over a 2.9s creep, then trips and dumps one fixed, calibrated 6% kick into the bar in 0.3s (the escapement's flat repeated release, not the mainspring's decay), rewinding forever, unbounded.

Use when an idle/demo sync-transfer indicator whose own loop is the point — a spring visibly winding then tripping into one discrete kick — for a job that is genuinely constant-force or discrete in real life (background sync, cache warm, queue drain) where the mechanism's motion itself is what should read as alive at rest, not a byte-accurate value. Pick progress-wick instead when a real external value arrives in bursts and the bar's job is to track that live target faithfully via draw-dwell interpolation, rather than run its own decorative wind-trip cycle underneath; pick progress-narrated instead when the point is naming a small number of discrete phases through a typed mono caption and a docked ledger rather than a continuous bar.

Install

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

import { useEffect, useId, useRef, useState } from "react";

// ---------------------------------------------------------------------------
// RemontoireRewind — a sync/upload progress strip driven by a constant-force
// remontoire instead of a plain glide. In a real remontoire d'egalite (e.g.
// Harrison's regulators) a small secondary spring is slowly wound by the
// mainspring, then trips and dumps ONE calibrated, constant-force release
// into the escapement, over and over — the escapement only ever feels the
// remontoire's flat repeated kick, never the mainspring's decaying torque.
// Here the spring glyph (an SVG coil, drawn fresh each frame as a sampled
// sine path so its pitch reads continuously, not as a swapped sprite) winds
// tighter — 5 visible turns compressing to 2 — over a slow "wind" phase,
// then snaps back out to 5 with one small overshoot on "trip". The bar
// underneath mirrors the same two-speed rhythm: a near-invisible 0.3%/s
// creep through the wind, then a fixed 6% kick — ~20x the creep rate — in
// the same instant the spring releases, so trip reads as a distinct payoff
// and never blurs into the creep. A brighter leading-edge segment (a pure
// --foreground luminance lift, never --ns-accent) flashes on that kick and
// eases back to the fill's resting opacity over 150ms.
//
// One real-time cycle = 3.2s (2.9s wind + 0.3s trip), compressed ~9.4x from
// a typical 30s precision-clock rearm interval — a discrete event, not a
// continuous oscillation, so it sits clear of any paint-rate aliasing band.
// The whole thing runs on one rAF loop keyed off real elapsed time (never
// frame count), self-driven, unbounded — nothing here waits on input.
//
// Optional controlled `value` (0-100) lets this sit on a real transfer: the
// bar's overall width then tracks `value` directly and the wind/trip cycle
// keeps running underneath purely as "work is happening" chrome layered on
// top, never touching the actual fill width itself.
// ---------------------------------------------------------------------------

const WIND_MS = 2900;
const TRIP_MS = 300;
const CYCLE_MS = WIND_MS + TRIP_MS;
const CREEP_PCT_PER_MS = 0.3 / 1000; // 0.3%/s
const TRIP_JUMP_PCT = 6;
const WIND_EASE_POWER = 1.6; // ease-in: slow start, accelerating tighten
const TURNS_RELAXED = 5;
const TURNS_WOUND = 2;
const FLASH_MS = 150;

const TRIP_BEZIER: [number, number, number, number] = [0.2, 1.4, 0.4, 1]; // one small overshoot

function clamp(n: number, lo: number, hi: number) {
  return Math.max(lo, Math.min(hi, n));
}

function bez1d(u: number, a: number, b: number, c: number, d: number) {
  const mu = 1 - u;
  return mu * mu * mu * a + 3 * mu * mu * u * b + 3 * mu * u * u * c + u * u * u * d;
}

// Solves x(u) = t for a cubic bezier anchored at (0,0)/(1,1) via bisection
// (x is monotonic since p1x/p2x sit in [0,1]), returns y(u) — which CAN
// exceed 1 mid-curve when a control point's y does, giving the overshoot.
function cubicBezierEase(t: number, [p1x, p1y, p2x, p2y]: [number, number, number, number]) {
  let lo = 0;
  let hi = 1;
  let u = t;
  for (let i = 0; i < 20; i++) {
    u = (lo + hi) / 2;
    const x = bez1d(u, 0, p1x, p2x, 1);
    if (x < t) lo = u;
    else hi = u;
  }
  return bez1d(u, 0, p1y, p2y, 1);
}

// Samples the coil as a plain sine wave across a 0-100 x span and returns an
// SVG path `d`. `turns` is the (continuous) number of full periods visible —
// fewer turns at the same span reads as coils bunched/compressed together,
// more turns reads as the loose, fully relaxed spring.
function springPath(turns: number, amplitude: number, samples: number) {
  let d = "";
  for (let i = 0; i <= samples; i++) {
    const x = (i / samples) * 100;
    const y = 12 + amplitude * Math.sin((i / samples) * turns * Math.PI * 2);
    d += i === 0 ? `M${x.toFixed(2)} ${y.toFixed(2)}` : `L${x.toFixed(2)} ${y.toFixed(2)}`;
  }
  return d;
}

function useReducedMotion() {
  const [reduced, setReduced] = useState(false);
  useEffect(() => {
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    setReduced(mq.matches);
    const onChange = () => setReduced(mq.matches);
    mq.addEventListener("change", onChange);
    return () => mq.removeEventListener("change", onChange);
  }, []);
  return reduced;
}

export interface RemontoireRewindProps {
  /** real transfer progress, 0-100. Omit to let the remontoire drive its own idle loop. */
  value?: number;
  /** visible + accessible label, e.g. "Syncing workspace" (default "Sync progress") */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function RemontoireRewind({ value, label = "Sync progress", className = "" }: RemontoireRewindProps) {
  const uid = useId();
  const labelId = `${uid}-label`;
  const reduced = useReducedMotion();

  const rootRef = useRef<HTMLDivElement>(null);
  const springRef = useRef<SVGPathElement>(null);
  const fillRef = useRef<HTMLDivElement>(null);
  const flashRef = useRef<HTMLDivElement>(null);
  const controlledValueRef = useRef(value);
  controlledValueRef.current = value;

  useEffect(() => {
    const root = rootRef.current;
    const spring = springRef.current;
    const fill = fillRef.current;
    const flash = flashRef.current;
    if (!root || !spring || !fill || !flash) return;

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

    // uncontrolled accumulation: how much of the strip the remontoire itself
    // has "delivered" so far this wrap, independent of any controlled value.
    let internalFill = 0;
    let fillAtTripStart = 0;
    let cycleStart = 0;
    let lastPhase: "wind" | "trip" = "wind";

    const paint = (now: number) => {
      let elapsed = now - cycleStart;
      if (elapsed >= CYCLE_MS) {
        const wraps = Math.floor(elapsed / CYCLE_MS);
        cycleStart += wraps * CYCLE_MS;
        elapsed -= wraps * CYCLE_MS;
      }

      let turns: number;
      let tripped = false;

      if (elapsed < WIND_MS) {
        const t = elapsed / WIND_MS;
        turns = TURNS_RELAXED - (TURNS_RELAXED - TURNS_WOUND) * Math.pow(t, WIND_EASE_POWER);
        if (lastPhase === "trip") {
          // just re-entered wind on a fresh cycle: lock in the post-trip baseline
          fillAtTripStart = internalFill;
        }
        lastPhase = "wind";
        internalFill = fillAtTripStart + elapsed * CREEP_PCT_PER_MS;
      } else {
        const t = clamp((elapsed - WIND_MS) / TRIP_MS, 0, 1);
        const eased = cubicBezierEase(t, TRIP_BEZIER);
        turns = TURNS_WOUND + (TURNS_RELAXED - TURNS_WOUND) * eased;
        if (lastPhase === "wind") fillAtTripStart = internalFill;
        lastPhase = "trip";
        const jumpEase = 1 - Math.pow(1 - t, 3); // ease-out, distinct kick
        internalFill = fillAtTripStart + jumpEase * TRIP_JUMP_PCT;
        tripped = t >= 1;
      }

      if (internalFill >= 100) internalFill -= 100; // seamless wrap, no pause at the seam

      const controlled = controlledValueRef.current;
      const displayFill = controlled === undefined ? internalFill : clamp(controlled, 0, 100);

      spring.setAttribute("d", springPath(turns, 4.5, 48));
      fill.style.width = `${displayFill}%`;

      if (tripped) {
        flash.style.transition = "none";
        flash.style.opacity = "1";
        // next frame: ease the flash back down over FLASH_MS
        requestAnimationFrame(() => {
          if (disposed) return;
          flash.style.transition = `opacity ${FLASH_MS}ms ease-out`;
          flash.style.opacity = "0";
        });
      }
    };

    const loop = (now: number) => {
      if (!visible) {
        raf = 0;
        return;
      }
      paint(now);
      raf = requestAnimationFrame(loop);
    };

    if (reduced) {
      // Deliberately chosen non-t0, most-structured freeze: mid-wind (turns
      // clearly between fully coiled and fully tripped) with the bar sitting
      // just after a trip-jump, so it reads as "a mechanism mid-cycle".
      spring.setAttribute("d", springPath(3.5, 4.5, 48));
      const controlled = controlledValueRef.current;
      fill.style.width = `${controlled === undefined ? 46 : clamp(controlled, 0, 100)}%`;
      flash.style.transition = "none";
      flash.style.opacity = "0.55";
    } else {
      cycleStart = performance.now();
      raf = requestAnimationFrame(loop);
    }

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

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      raf = 0;
      io.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps -- `value` is read live via controlledValueRef, not a loop dependency
  }, [reduced]);

  const valueNow = value === undefined ? undefined : Math.round(clamp(value, 0, 100));

  return (
    <div ref={rootRef} className={className}>
      <div className="mb-2 flex items-baseline justify-between gap-3">
        <span id={labelId} className="font-mono text-xs text-ns-muted">
          {label}
        </span>
        {valueNow !== undefined ? (
          <span className="font-mono text-xs tabular-nums text-foreground" aria-hidden>
            {valueNow}%
          </span>
        ) : null}
      </div>

      <svg
        aria-hidden="true"
        focusable="false"
        viewBox="0 0 100 24"
        preserveAspectRatio="none"
        className="mb-1 h-6 w-full text-foreground"
      >
        <path ref={springRef} d="" fill="none" stroke="currentColor" strokeWidth={1.4} strokeLinecap="round" strokeLinejoin="round" />
      </svg>

      <div
        role="progressbar"
        aria-labelledby={labelId}
        aria-valuemin={0}
        aria-valuemax={100}
        aria-valuenow={valueNow}
        aria-valuetext={valueNow === undefined ? "Syncing" : `${valueNow}%`}
        className="relative h-1.5 overflow-hidden rounded-full border border-border bg-ns-muted/20"
      >
        <div ref={fillRef} className="absolute inset-y-0 left-0 w-0 rounded-full bg-foreground/85">
          <div ref={flashRef} className="absolute inset-y-0 right-0 w-2.5 rounded-full bg-foreground opacity-0" />
        </div>
      </div>
    </div>
  );
}
Build spec

A progress `role=progressbar` strip whose motion is a constant-force remontoire, not a plain glide. A coiled-spring SVG path (a 100x24 viewBox, `preserveAspectRatio="none"` so it stretches to the container's width while its height stays fixed like a sibling bar's track) is resampled every animation frame as a plain sine wave — `y = 12 + amplitude * sin((i/samples) * turns * 2*PI)` across 49 samples — where `turns`, a continuous float, is the ONE value driving the whole glyph: fewer turns at the same horizontal span reads as coils bunched/compressed together (wound), more turns reads as the loose, fully relaxed spring (just tripped). One real-time cycle is a fixed 3.2s (2900ms wind + 300ms trip), compressed ~9.4x from a typical 30s precision-clock remontoire rearm interval, run on a single rAF loop keyed off real elapsed time via `performance.now()` (never frame count, never a CSS keyframe animation), unbounded and self-driven — nothing here waits on pointer/press/scroll input, hence `autoplay: none`. During the wind phase, `turns` eases from 5 down to 2 via `5 - 3 * t^1.6` (t = elapsed/2900, a power-based ease-in: slow to start, visibly accelerating into the trip, approximating a spring's rising resistance near full wind) and the uncontrolled fill accumulator creeps forward at a fixed 0.3%/s (barely perceptible on its own). During the 300ms trip phase, `turns` eases back from 2 to 5 through a hand-solved cubic-bezier(.2,1.4,.4,1) — solved via 20 iterations of bisection on the bezier's x(u)=t before reading y(u), since the y control point (1.4) sits above 1 and produces exactly the spec's 'one small overshoot' past 5 before settling — while the fill accumulator, independently, jumps forward a fixed 6% (roughly 20x the creep rate) via `1 - (1-t)^3` ease-out so the trip reads as a distinct kick, never a continuation of the creep; the accumulator wraps at 100% back to 0% with no pause at the seam (an idle/demo loop, not a terminating transfer). The instant a trip completes, a small leading-edge `<div>` (absolutely positioned at the fill's own right edge, `bg-foreground` at full opacity) flashes to opacity 1 with `transition: none`, then on the very next frame gets a `150ms ease-out` transition back to opacity 0 — a pure luminance lift on `--foreground`, never `--ns-accent`, matching the binding rule that accent stays interaction-only chrome. Optional controlled `value` (0-100): when supplied, the fill `<div>`'s width tracks `clamp(value, 0, 100)` directly every frame instead of the internal accumulator, while the spring glyph and its trip flash keep running their own 3.2s wind/trip clock untouched — pure 'work is happening' chrome layered over a real reading, exactly as the spec requires; `aria-valuenow`/`aria-valuetext` mirror the same controlled value, and omit `aria-valuenow` (falling back to `aria-valuetext="Syncing"`) when uncontrolled, since the internal accumulator is decorative, not a real percentage. Track is `border border-border` (separator only) over a `bg-ns-muted/20` base fill — never `--border` itself as the empty-track fill, per the binding token rule that `--border`'s ~1.1:1 light-theme contrast is invisible as a fill. `prefers-reduced-motion` skips the rAF loop entirely and freezes on turns=3.5 (deliberately mid-wind — neither the 5-turn 'just tripped' start nor a fully wound 2-turn end) with the bar sitting at a static 46% (uncontrolled) or the live controlled value, and the leading-edge flash held at a static opacity 0.55 with no transition — reads as 'a mechanism mid-cycle', not a single fired moment. An IntersectionObserver pauses the rAF loop whenever the strip scrolls offscreen and resumes it on return; cleanup cancels the rAF and disconnects the observer on unmount. Props: `value` (0-100, optional, uncontrolled ambient loop when omitted), `label` (default "Sync progress"), `className`. Zero dependencies, DOM+SVG+CSS only, no canvas, every colour a token (`--background --foreground --ns-muted --border`), `--ns-accent` unused entirely since this component has no interaction chrome to spend it on.

Props

PropTypeDefaultDescription
value?numberreal transfer progress, 0-100. Omit to let the remontoire drive its own idle loop.
label?string"Sync progress"visible + accessible label, e.g. "Syncing workspace" (default "Sync progress")
className?stringextra classes merged onto the rendered root element