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.jsonSource
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
| Prop | Type | Default | Description |
|---|---|---|---|
| className? | string | — | — |
| style? | React.CSSProperties | — | — |