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.

Usage

registry/loud/rotor-window-bank/demo.tsx
"use client";

import { RotorWindowBank } from "./component";

// Unconditional idle loop from mount — the wheels tick, kick, and
// double-step entirely on their own clock, so the demo needs nothing beyond
// mounting the bank itself.
export default function RotorWindowBankDemo() {
  return (
    <main className="h-screen w-full overflow-hidden bg-background">
      <RotorWindowBank />
    </main>
  );
}

Component

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";

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

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.

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——