Skip to main content

ns-ui / loud

Rotor Window Bank

Full-bleed hero backdrop modeled on a rotor cipher machine's stepping mechanism: three letter-wheel windows where the right wheel ticks steadily, kicks the middle wheel once per revolution, and the middle wheel occasionally double-steps, moving twice in one beat and dragging the left wheel with it, the real Enigma-family stepping anomaly compressed to a 9-position ring so it recurs roughly every minute instead of every eleven.

Use when a full-bleed ambient hero/background whose motion comes from a real electromechanical stepping mechanism rather than an authored keyframe loop, where the occasional double-step is the payoff worth watching for; pick curtain-leader-countdown instead when the full-bleed moment needs to resolve to a completion/cut rather than run as an unbounded ambient backdrop, or text-decrypt instead when the point is a specific string resolving left-to-right rather than an abstract mechanism ticking forever.

Install

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

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

// ---------------------------------------------------------------------------
// RotorWindowBank — a full-bleed ambient hero backdrop modeled on the rotor
// stepping mechanism of an electromechanical rotor cipher machine
// (Enigma-family), not the electrical scrambling it fed. Three letter-wheel
// windows sit side by side; a pawl advances the right wheel one position on
// a fixed tick, and when the right wheel completes a full revolution it
// kicks the middle wheel forward. The middle wheel carries the historical
// "double-step" anomaly: if the middle wheel is itself sitting on one of its
// own notch positions at the instant it would be kicked, it steps TWICE that
// beat and also drags the left wheel forward with it — the mechanical quirk
// that gave the real machine a period of 26x25x26 instead of a clean 26^3.
//
// COMPRESSED, NOT LITERAL. The real ring is 26 positions; a double-step on a
// single-notch 26-ring machine recurs roughly once every 676 ticks (~11
// minutes at 1 tick/s) — invisible at showpiece timescales. This component
// compresses the ring to RING=9 positions and gives the middle wheel TWO
// notches (0 and 4, an uneven split) so the double-step — the entire reason
// this mechanism is interesting to look at — recurs roughly every 50-65s
// instead. Ring size and notch count are a stated departure from the
// historical 26-position, single-notch rotor, not a fidelity claim.
//
// Positions are driven by a small event-queue simulation, not CSS
// `animation: infinite`: the right wheel's position is a pure function of
// elapsed sim time (deterministic, replayable), while the middle and left
// wheels only move in response to discrete "kick" events computed
// incrementally as the right wheel's tick counter crosses each multiple of
// RING. This lets the double-step choreography (two 220ms steps with a 90ms
// hold between them, then a delayed left-wheel kick) be scheduled exactly
// once per event rather than re-derived from scratch every frame.
//
// Direct-DOM rAF: no React state on the hot path. Every wheel's three
// visible glyph rows are plain refs; transform/opacity are written straight
// to style each frame. Colour comes entirely from Tailwind classes bound to
// the --background/--foreground/--border tokens (currentColor + opacity),
// so theme flips repaint for free with zero JS token reads — there is
// nothing here that touches canvas or a shader, so no getComputedStyle pass
// is needed at all.
// ---------------------------------------------------------------------------

export interface RotorWindowBankProps {
  className?: string;
  style?: React.CSSProperties;
}

const RING = 9; // compressed from the historical 26-position ring
const GLYPHS = ["A", "B", "C", "D", "E", "F", "G", "H", "I"];
const MIDDLE_NOTCHES = [0, 4]; // two notches (vs. the historical single notch)
const TICK_MS = 1400; // right wheel: 1 tick / 1.4s
const STEP_MS = 220; // single-wheel step transition
const HOLD_MS = 90; // hold between a double-step's two moves

// The frozen reduced-motion frame: right wheel at position 4, middle wheel
// one tick past a notch, left wheel one position advanced — the frame that
// shows all three wheels having just moved relative to each other, not the
// default single-wheel idle state.
const STATIC_RIGHT = 4;
const STATIC_MIDDLE = 1;
const STATIC_LEFT = 1;

function easeOutCubic(t: number): number {
  const c = Math.max(0, Math.min(1, t));
  return 1 - Math.pow(1 - c, 3);
}

interface QueueEvent {
  atMs: number;
  fromPos: number;
  toPos: number;
}

/** Advance a settled-position + pending-transition queue and return the
 * current continuous position (integer part = ring position, fractional
 * part = in-flight transition progress). Committing an event pops it and
 * updates settledPos in place; a queue can hold at most two entries (the
 * two halves of a double-step) so this never grows unbounded. */
function stepQueue(
  queue: QueueEvent[],
  settledPos: { current: number },
  simMs: number
): number {
  while (queue.length && queue[0].atMs + STEP_MS <= simMs) {
    settledPos.current = queue[0].toPos;
    queue.shift();
  }
  if (queue.length && queue[0].atMs <= simMs) {
    const ev = queue[0];
    const frac = easeOutCubic((simMs - ev.atMs) / STEP_MS);
    return ev.fromPos + (ev.toPos - ev.fromPos) * frac;
  }
  return settledPos.current;
}

/** Row centring weight for a row at relative index r (0,1,2 = base-1,base,
 * base+1) given continuous position frac (0..1 into the current step). */
function centerness(r: number, frac: number): number {
  return Math.max(0, 1 - Math.abs(1 + frac - r));
}

interface WheelRows {
  rows: HTMLDivElement[]; // exactly 3, relative index -1, 0, +1
}

// Window is 1.6 row-heights tall so the neighbour above/below the centred
// glyph peeks in at its edges (the "next glyph creeping into frame" cue).
// Row r's own height equals one row-height, so translateY in % of its own
// box is resolution-independent — no px math needed in JS.
function paintWheel(wheel: WheelRows | null, continuousPos: number) {
  if (!wheel) return;
  const base = Math.floor(continuousPos);
  const frac = continuousPos - base;
  for (let r = 0; r < 3; r++) {
    const el = wheel.rows[r];
    if (!el) continue;
    const ringIndex = (((base - 1 + r) % RING) + RING) % RING;
    const glyph = GLYPHS[ringIndex];
    if (el.textContent !== glyph) el.textContent = glyph;
    const w = centerness(r, frac);
    el.style.opacity = String(0.35 + 0.55 * w);
    el.style.transform = `translateY(${(r - 0.7 - frac) * 100}%)`;
  }
}

export function RotorWindowBank({ className = "", style }: RotorWindowBankProps) {
  const uid = useId();
  const wrapRef = useRef<HTMLDivElement>(null);
  const stripRefs = useRef<(HTMLDivElement | null)[]>([null, null, null]);
  const rightRows = useRef<(HTMLDivElement | null)[]>([null, null, null]);
  const middleRows = useRef<(HTMLDivElement | null)[]>([null, null, null]);
  const leftRows = useRef<(HTMLDivElement | null)[]>([null, null, null]);

  useEffect(() => {
    const wrap = wrapRef.current;
    if (!wrap) return;

    let disposed = false;
    let running = false;
    let raf = 0;
    let lastMs = 0;
    // Random phase offset so a fresh mount never lands on a clean idle
    // state — the loop is already "mid-machine" from the first frame.
    let simMs = Math.random() * TICK_MS;

    const middleQueue: QueueEvent[] = [];
    const leftQueue: QueueEvent[] = [];
    const middleSettled = { current: 0 };
    const leftSettled = { current: 0 };
    let middleStepsCum = 0;
    let leftStepsCum = 0;
    let kicksProcessed = 0;
    let lastTickIndex = -1;

    const processKicks = (tickIndex: number) => {
      const kicksNow = Math.floor(tickIndex / RING);
      while (kicksProcessed < kicksNow) {
        kicksProcessed++;
        const kickTickIndex = kicksProcessed * RING;
        const kickAtMs = kickTickIndex * TICK_MS;
        const beforePos = middleStepsCum % RING;
        const isDouble = MIDDLE_NOTCHES.includes(beforePos);

        middleQueue.push({ atMs: kickAtMs, fromPos: middleStepsCum, toPos: middleStepsCum + 1 });
        middleStepsCum += 1;

        if (isDouble) {
          const secondAt = kickAtMs + STEP_MS + HOLD_MS;
          middleQueue.push({ atMs: secondAt, fromPos: middleStepsCum, toPos: middleStepsCum + 1 });
          middleStepsCum += 1;

          const leftAt = secondAt + STEP_MS; // after the middle wheel's second step lands
          leftQueue.push({ atMs: leftAt, fromPos: leftStepsCum, toPos: leftStepsCum + 1 });
          leftStepsCum += 1;
        }
      }
    };

    const draw = (ms: number) => {
      const tickIndex = Math.floor(ms / TICK_MS);
      if (tickIndex !== lastTickIndex) {
        processKicks(tickIndex);
        lastTickIndex = tickIndex;
      }
      const tLocal = ms - tickIndex * TICK_MS;
      const rightPos =
        tLocal < STEP_MS
          ? tickIndex - 1 + easeOutCubic(tLocal / STEP_MS)
          : tickIndex;
      const middlePos = stepQueue(middleQueue, middleSettled, ms);
      const leftPos = stepQueue(leftQueue, leftSettled, ms);

      paintWheel({ rows: rightRows.current as HTMLDivElement[] }, rightPos);
      paintWheel({ rows: middleRows.current as HTMLDivElement[] }, middlePos);
      paintWheel({ rows: leftRows.current as HTMLDivElement[] }, leftPos);
    };

    const drawStatic = () => {
      paintWheel({ rows: leftRows.current as HTMLDivElement[] }, STATIC_LEFT);
      paintWheel({ rows: middleRows.current as HTMLDivElement[] }, STATIC_MIDDLE);
      paintWheel({ rows: rightRows.current as HTMLDivElement[] }, STATIC_RIGHT);
    };

    let staticMode = false;

    const loopFrame = (now: number) => {
      if (!running) return;
      if (!lastMs) lastMs = now;
      const dt = Math.min(100, now - lastMs); // clamp so a tab-switch gap can't leap the sim
      lastMs = now;
      simMs += dt;
      draw(simMs);
      raf = requestAnimationFrame(loopFrame);
    };

    const wake = () => {
      if (running || disposed || staticMode) return;
      running = true;
      lastMs = 0;
      raf = requestAnimationFrame(loopFrame);
    };
    const sleep = () => {
      running = false;
      if (raf) cancelAnimationFrame(raf);
      raf = 0;
    };

    // Paint synchronously before anything async (rAF, observers) can run so
    // there is never a blank first frame while waiting on the loop to wake.
    draw(simMs);

    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    const applyMode = () => {
      if (mq.matches) {
        staticMode = true;
        sleep();
        drawStatic();
      } else {
        staticMode = false;
        wake();
      }
    };
    const onMq = () => applyMode();
    mq.addEventListener("change", onMq);

    let onScreen = true;
    const io = new IntersectionObserver(
      (entries) => {
        onScreen = entries.some((en) => en.isIntersecting);
        if (!onScreen) sleep();
        else if (!staticMode && !document.hidden) wake();
      },
      { threshold: 0 }
    );
    io.observe(wrap);

    const onVis = () => {
      if (document.hidden) sleep();
      else if (!staticMode && onScreen) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    // Geometry derives from the container's smaller dimension so this reads
    // correctly at card scale, not only full-bleed.
    const applySize = () => {
      const rect = wrap.getBoundingClientRect();
      const base = Math.max(1, Math.min(rect.width, rect.height));
      const rowH = base / 5;
      wrap.style.setProperty("--rotor-row-h", `${rowH}px`);
    };
    const ro = new ResizeObserver(applySize);
    ro.observe(wrap);
    applySize();

    applyMode();

    return () => {
      disposed = true;
      sleep();
      ro.disconnect();
      io.disconnect();
      mq.removeEventListener("change", onMq);
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  const wheel = (rowsRef: React.MutableRefObject<(HTMLDivElement | null)[]>, key: string) => (
    <div
      className="relative overflow-hidden"
      style={{ width: "calc(var(--rotor-row-h) * 0.9)", height: "calc(var(--rotor-row-h) * 1.6)" }}
    >
      {[0, 1, 2].map((r) => (
        <div
          key={`${key}-${r}`}
          ref={(el) => {
            rowsRef.current[r] = el;
          }}
          className="absolute inset-x-0 top-0 flex items-center justify-center font-mono text-foreground"
          style={{
            height: "var(--rotor-row-h)",
            fontSize: "calc(var(--rotor-row-h) * 0.55)",
          }}
        />
      ))}
    </div>
  );

  return (
    <div
      ref={wrapRef}
      data-rotor-window-bank={uid}
      aria-hidden="true"
      className={`relative flex h-full w-full items-center justify-center gap-[calc(var(--rotor-row-h)*0.35)] overflow-hidden bg-background ${className}`}
      style={{ ...style, ["--rotor-row-h" as string]: "48px" }}
    >
      <div className="flex items-stretch divide-x divide-border border border-border">
        <div className="p-[calc(var(--rotor-row-h)*0.25)]">{wheel(leftRows, "left")}</div>
        <div className="p-[calc(var(--rotor-row-h)*0.25)]">{wheel(middleRows, "middle")}</div>
        <div className="p-[calc(var(--rotor-row-h)*0.25)]">{wheel(rightRows, "right")}</div>
      </div>
    </div>
  );
}

RotorWindowBank.displayName = "RotorWindowBank";
Build spec

Build a full-bleed DOM-only (no canvas/WebGL) hero backdrop showing three rotor-machine letter-wheel windows side by side (left, middle, right), each an overflow-hidden window 1.6 row-heights tall over a 3-row glyph strip, row-height = min(containerWidth, containerHeight) / 5 recomputed on a ResizeObserver so it reads at card scale as well as full-bleed. RING=9 positions per wheel cycling glyphs A-I (compressed from the historical 26-position Enigma-family ring, documented in a code comment as a stated departure, not a fidelity claim). MOTION: the right wheel advances one position every TICK_MS=1400ms via a 220ms ease-out-cubic step transition then holds; every 9th right-wheel tick (one full revolution) it kicks the middle wheel forward one position. The middle wheel carries TWO notches (positions 0 and 4 of its own 9-position ring, an uneven split so the interval between recurrences is irregular, not a fixed multiple): if the middle wheel sits on either notch at the instant it would be kicked, that beat plays as a double-step instead of a single step — two back-to-back 220ms transitions with a 90ms hold between them (the middle wheel visibly moves, pauses, moves again), and a left-wheel kick (its own 220ms step) fires starting immediately after the middle wheel's second step transition completes, never simultaneously with it. With these numbers the double-step recurs roughly every 50-65s (alternating 4-kick and 5-kick gaps at 12.6s/kick), which is the entire visible point of the mechanism, so DO NOT compress the ring further or the anomaly stops reading as an event. Drive all three wheels from a single rAF loop accumulating real elapsed ms into a `simMs` counter (dt clamped to 100ms so a tab-switch gap can't skip ticks); the right wheel's position is a pure function of simMs, while the middle/left wheels are driven by a small per-wheel queue of at most two pending step events, computed incrementally exactly once each time the right wheel's tick counter crosses a multiple of 9 (never recomputed from t=0, so this stays cheap over an arbitrarily long mount). RENDERING: each wheel's 3 rows are refs updated with direct style writes (textContent, opacity, transform: translateY in % of the row's own height, formula `(r - 0.7 - frac) * 100` for relative row index r in {0,1,2} and continuous fractional step position frac) — zero React state on the hot path. Row opacity = 0.35 + 0.55 * max(0, 1 - |1 + frac - r|), giving the centred glyph ~0.9 opacity and the peeking neighbours above/below ~0.35, crossfading continuously through a step rather than a hard cut. On mount, simMs starts at a random offset in [0, 1400) so a fresh load never lands on a clean idle frame, and draw(simMs) is called synchronously before the rAF loop starts so there is no blank first frame. COLOUR: every visible surface uses Tailwind classes bound to --background/--foreground/--border tokens (text-foreground for glyphs, border-border/divide-border for the wheel-window frame and dividers, bg-background for the field) — no JS colour reads needed since this is pure DOM/CSS, no accent anywhere (this is a non-interactive ambient background). LIFECYCLE: pause the rAF loop via IntersectionObserver (threshold 0) when scrolled offscreen and on document visibilitychange when hidden, resuming exactly where simMs left off; disconnect every observer and cancel the rAF on unmount. REDUCED MOTION: a `prefers-reduced-motion` matchMedia listener freezes on STATIC_RIGHT=4, STATIC_MIDDLE=1, STATIC_LEFT=1 (the frame right after a double-step resolves, showing all three wheels having just moved relative to one another) with no rAF running at all. The whole element is `aria-hidden` and non-interactive; no click/hover affordance of any kind.

Props

PropTypeDefaultDescription
className?string
style?React.CSSProperties