Skip to main content

ns-ui

Bombe Drum Halt

An ambient search/lookup loading indicator built as the electromechanical Bombe's drum-and-stop search cycle: four drum columns scan continuously until, every 4-6s, all of them lock still at once while a sensing bar brightens for a check, then release and resume.

Usage

registry/core/bombe-drum-halt/demo.tsx
"use client";

import { BombeDrumHalt } from "./component";

export default function BombeDrumHaltDemo() {
  return (
    <main className="min-h-screen bg-background">
      <header className="flex items-center justify-between gap-4 border-b border-border px-6 py-4">
        <p className="font-mono text-xs tracking-widest text-ns-muted">ns-ui / bombe-drum-halt</p>
        <p className="hidden font-mono text-[11px] text-ns-muted sm:block">
          drums scan continuously, halt every 4-6s while a check runs, then resume
        </p>
      </header>
      <div className="flex min-h-[calc(100vh-3.75rem)] items-center justify-center p-10">
        <div className="flex w-full max-w-md flex-col items-center gap-6 rounded-xl border border-border bg-surface p-10">
          <BombeDrumHalt className="h-64 w-64" label="Searching rotor positions" />
          <p className="text-center font-mono text-[11px] text-ns-muted">
            no known endpoint or percentage — an ambient search indicator, not a progress bar
          </p>
        </div>
      </div>
    </main>
  );
}

Component

registry/core/bombe-drum-halt/component.tsx
"use client";

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

// ---------------------------------------------------------------------------
// BombeDrumHalt — a search/lookup loading indicator built as the Bombe's
// drum-and-stop search cycle: banks of rotating drums spin continuously
// through a hypothesis space; a sensing relay bank tests each position as
// the drums pass it, and when a candidate briefly satisfies the test the
// whole bank locks dead for a beat (a diagonal-board-style check running
// against it) before releasing and resuming — almost every stop a false
// positive. Sourced from the drum-and-stop search cycle itself, not the
// electrical/plugboard logic the real machine tested.
//
// Four vertical drum columns, each an overflow:hidden window over a
// repeating tick-mark strip. The strip is NOT a uniform 1-tick gradient —
// a uniform pattern repeats every single tick, which makes a drum's visible
// state alias back to itself dozens of times a second and leaves t0/2.5s/5s
// screenshots indistinguishable whenever no halt happens to be mid-flight.
// Instead each drum's strip is one 26-position revolution (one per rotor
// letter, historically the right count for a Bombe drum): every 5th
// position is a heavier tick and position 0 is a distinct brighter datum
// mark, so a screenshot of a drum's resting phase is legible and never
// repeats for ~2s of continuous scan.
//
// All four columns share ONE driving clock (a single rAF loop tracking one
// `phase` state machine: scan -> decel -> hold -> reaccel -> scan) rather
// than four independent CSS animation timelines, specifically so a halt can
// pause that clock precisely instead of fighting four separately-running
// loops back into sync. Each drum's on-screen offset is (accumulated
// scroll px) mod (one full 26-tick revolution); the per-drum phase stagger
// (drum N reads N*0.6s "ahead") is nothing more than a different starting
// offset on that same shared clock, so the drums never need independent
// timers to stay out of lock-step.
//
// A halt is scheduled 4-6s out (re-rolled after every resume, never a fixed
// metronome). Decel eases speed 1 -> 0 over 180ms while the sensing bar
// brightens from its idle 20% opacity toward peak; hold hangs at zero
// velocity for 900ms at 90% bar opacity (the "check running" beat); reaccel
// eases 0 -> 1 back over 220ms as the bar dims back to idle. On 15% of
// resumes a single randomly-chosen drum gets a 40%-opacity outline ring at
// the sensing line (where its current tick mark sits under test) for one
// extra 600ms after motion resumes — a rarer "this one got a second look"
// variant that still resolves and never latches.
//
// prefers-reduced-motion renders the single most-structured frame directly
// (no rAF loop at all): drums stopped at their phase-staggered rest
// offsets, sensing bar at peak 90% opacity — the mid-hold instant, not a
// mid-scroll blur.
// ---------------------------------------------------------------------------

// Rendered scan speed is a decoupled rate, not the historical one: the real
// Bombe tested on the order of hundreds of rotor-hypotheses/s, far above
// anything that reads as motion (rather than noise/strobe) on a 60Hz
// screen. 180px/s here is a visually smooth continuous scroll — roughly 13
// tick-marks/s at the default tick spacing — chosen the same way round-9's
// rate-decoupling rule chose a slow sweep over a literal 180marks/s
// (2520px/s), which would alias against 60Hz paint exactly like the strobe
// bug that rule was written for.
const SCROLL_SPEED_PX_S = 180;
const STRIP_TICKS = 26; // one revolution = 26 rotor-letter positions
const DRUM_COUNT = 4;
const PHASE_STEP_S = 0.6; // drum N starts N*0.6s "ahead" of drum 0
const HALT_MIN_S = 4;
const HALT_MAX_S = 6;
const DECEL_MS = 180;
const HOLD_MS = 900;
const REACCEL_MS = 220;
const RING_CHANCE = 0.15;
const RING_MS = 600;
const SENSING_BAR_Y_RATIO = 0.42; // fixed height across drums, fraction of drum height
const BAR_IDLE_OPACITY = 0.2;
const BAR_PEAK_OPACITY = 0.9;

type Phase = "scan" | "decel" | "hold" | "reaccel";

function easeInOutCubic(t: number): number {
  return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}

interface Tick {
  top: number;
  height: number;
  opacity: number;
}

/** Builds `repeats` back-to-back 26-position revolutions of tick marks —
 * a datum mark every 26th position, a heavier mark every 5th, a plain tick
 * otherwise — so the drum window always has enough rendered strip above
 * and below the visible offset range to cover a full modulo sweep. */
function buildTicks(tickPeriod: number, repeats: number): Tick[] {
  const ticks: Tick[] = [];
  const total = STRIP_TICKS * repeats;
  for (let i = 0; i < total; i++) {
    const pos = i % STRIP_TICKS;
    const isDatum = pos === 0;
    const isHeavy = pos % 5 === 0;
    ticks.push({
      top: i * tickPeriod,
      height: isDatum ? tickPeriod * 0.32 : isHeavy ? tickPeriod * 0.22 : tickPeriod * 0.14,
      opacity: isDatum ? 0.9 : isHeavy ? 0.68 : 0.45,
    });
  }
  return ticks;
}

export interface BombeDrumHaltProps {
  /** accessible label for the ambient search/loading status */
  label?: string;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function BombeDrumHalt({ label = "Searching", className = "" }: BombeDrumHaltProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const drumRefs = useRef<(HTMLDivElement | null)[]>([]);
  const ringRefs = useRef<(HTMLDivElement | null)[]>([]);
  const barRef = useRef<HTMLDivElement>(null);
  const [smaller, setSmaller] = useState(0);

  useEffect(() => {
    const root = rootRef.current;
    if (!root) return;
    const ro = new ResizeObserver((entries) => {
      const entry = entries[0];
      if (!entry) return;
      const { width, height } = entry.contentRect;
      setSmaller(Math.min(width, height));
    });
    ro.observe(root);
    return () => ro.disconnect();
  }, []);

  // Geometry derives from the container's smaller dimension so the
  // component reads at card scale regardless of aspect ratio.
  const drumWidth = smaller > 0 ? smaller / 9 : 0;
  const drumHeight = smaller > 0 ? smaller * 0.85 : 0;
  const tickPeriod = drumHeight > 0 ? Math.max(4, drumHeight / 45) : 4;
  const stripLength = STRIP_TICKS * tickPeriod;
  const repeats = drumHeight > 0 ? Math.max(3, Math.ceil((drumHeight + stripLength) / stripLength) + 1) : 3;
  const ticks = useMemo(
    () => (drumHeight > 0 ? buildTicks(tickPeriod, repeats) : []),
    [tickPeriod, repeats, drumHeight]
  );

  useEffect(() => {
    if (drumWidth <= 0 || drumHeight <= 0) return;

    const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

    // accumulated scroll offset per drum, px, seeded by the phase stagger
    const positions = Array.from(
      { length: DRUM_COUNT },
      (_, i) => i * PHASE_STEP_S * SCROLL_SPEED_PX_S
    );

    const applyDrumTransform = (i: number) => {
      const el = drumRefs.current[i];
      if (!el) return;
      const offset = ((positions[i] ?? 0) % stripLength + stripLength) % stripLength;
      el.style.transform = `translateY(${-offset}px)`;
    };

    const setBarOpacity = (v: number) => {
      const el = barRef.current;
      if (el) el.style.opacity = String(v);
    };

    const setRing = (drum: number, opacity: number) => {
      ringRefs.current.forEach((el, i) => {
        if (el) el.style.opacity = i === drum ? String(opacity) : "0";
      });
    };

    if (reduced) {
      // freeze on the mid-hold instant: drums stopped, bar at peak brightness
      positions.forEach((_, i) => applyDrumTransform(i));
      setBarOpacity(BAR_PEAK_OPACITY);
      setRing(-1, 0);
      return;
    }

    let raf = 0;
    let visible = true;
    let last = 0;
    let phase: Phase = "scan";
    let phaseStart = 0;
    let nextHaltAt = 0;
    let ringDrum = -1;
    let ringUntil = 0;

    const scheduleNextHalt = (now: number) => {
      nextHaltAt = now + (HALT_MIN_S + Math.random() * (HALT_MAX_S - HALT_MIN_S)) * 1000;
    };

    const advance = (dt: number, speedFactor: number) => {
      const delta = (SCROLL_SPEED_PX_S * speedFactor * dt) / 1000;
      for (let i = 0; i < positions.length; i++) {
        positions[i] = (positions[i] ?? 0) + delta;
      }
    };

    const tick = (now: number) => {
      raf = requestAnimationFrame(tick);
      if (!visible) return;
      if (last === 0) last = now;
      const dt = Math.min(100, now - last);
      last = now;

      if (phase === "scan") {
        if (nextHaltAt === 0) scheduleNextHalt(now);
        advance(dt, 1);
        if (ringDrum >= 0 && now >= ringUntil) {
          setRing(-1, 0);
          ringDrum = -1;
        }
        if (now >= nextHaltAt) {
          phase = "decel";
          phaseStart = now;
        }
      } else if (phase === "decel") {
        const t = Math.min(1, (now - phaseStart) / DECEL_MS);
        advance(dt, 1 - easeInOutCubic(t));
        setBarOpacity(BAR_IDLE_OPACITY + (BAR_PEAK_OPACITY - BAR_IDLE_OPACITY) * t);
        if (t >= 1) {
          phase = "hold";
          phaseStart = now;
        }
      } else if (phase === "hold") {
        const t = Math.min(1, (now - phaseStart) / HOLD_MS);
        setBarOpacity(BAR_PEAK_OPACITY);
        if (t >= 1) {
          phase = "reaccel";
          phaseStart = now;
        }
      } else if (phase === "reaccel") {
        const t = Math.min(1, (now - phaseStart) / REACCEL_MS);
        advance(dt, easeInOutCubic(t));
        setBarOpacity(BAR_PEAK_OPACITY - (BAR_PEAK_OPACITY - BAR_IDLE_OPACITY) * t);
        if (t >= 1) {
          phase = "scan";
          scheduleNextHalt(now);
          // false-positive resolve: 15% of halts leave a "second look" ring
          if (Math.random() < RING_CHANCE) {
            ringDrum = Math.floor(Math.random() * DRUM_COUNT);
            ringUntil = now + RING_MS;
            setRing(ringDrum, 0.4);
          }
        }
      }

      for (let i = 0; i < DRUM_COUNT; i++) applyDrumTransform(i);
    };

    raf = requestAnimationFrame(tick);

    const io = new IntersectionObserver((entries) => {
      const wasVisible = visible;
      visible = entries[0]?.isIntersecting ?? true;
      if (visible && !wasVisible) {
        last = 0; // resync elapsed-time base on resume
        // A halt frozen mid-flight while offscreen has a stale phaseStart;
        // rather than resolve decel/hold/reaccel in one jumped frame,
        // treat the away period as invalidating it and resume scanning.
        if (phase !== "scan") {
          phase = "scan";
          setBarOpacity(BAR_IDLE_OPACITY);
          setRing(-1, 0);
          ringDrum = -1;
        }
        scheduleNextHalt(performance.now());
      }
    });
    if (rootRef.current) io.observe(rootRef.current);

    return () => {
      cancelAnimationFrame(raf);
      io.disconnect();
    };
  }, [drumWidth, drumHeight, stripLength]);

  const drums = useMemo(() => Array.from({ length: DRUM_COUNT }, (_, i) => i), []);
  const ringInset = Math.max(1, drumWidth * 0.1);

  return (
    <div
      ref={rootRef}
      role="status"
      aria-label={label}
      className={`relative flex h-full w-full min-h-[120px] items-center justify-center ${className}`}
    >
      <div className="relative flex items-stretch" style={{ gap: drumWidth * 0.35 }}>
        {drums.map((i) => (
          <div
            key={i}
            className="relative overflow-hidden"
            style={{
              width: drumWidth,
              height: drumHeight,
              borderLeft: i === 0 ? "none" : "1px solid var(--border)",
            }}
          >
            <div
              ref={(el) => {
                drumRefs.current[i] = el;
              }}
              className="absolute inset-x-0 top-0 will-change-transform"
              style={{ height: repeats * stripLength }}
            >
              {ticks.map((t, idx) => (
                <div
                  key={idx}
                  className="absolute inset-x-0"
                  style={{
                    top: t.top,
                    height: t.height,
                    background: "var(--foreground)",
                    opacity: t.opacity,
                  }}
                />
              ))}
            </div>
            <div
              ref={(el) => {
                ringRefs.current[i] = el;
              }}
              className="pointer-events-none absolute"
              style={{
                left: ringInset,
                right: ringInset,
                top: drumHeight * SENSING_BAR_Y_RATIO - tickPeriod / 2,
                height: tickPeriod,
                border: "1px solid var(--foreground)",
                borderRadius: 1,
                opacity: 0,
                transition: "opacity 200ms ease",
              }}
            />
          </div>
        ))}
        <div
          ref={barRef}
          className="pointer-events-none absolute left-0 right-0"
          style={{
            top: drumHeight * SENSING_BAR_Y_RATIO,
            height: 1,
            background: "var(--foreground)",
            opacity: BAR_IDLE_OPACITY,
          }}
        />
      </div>
    </div>
  );
}

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

Use when an ambient indicator for a search/lookup with no known endpoint or percentage (checking many candidates against a test) where the story is scan-then-halt-then-resume rather than steady linear progress; pick loader-braille instead for a compact inline spinner with no discrete halt beat, or loader-pendulum-sync for a long unattended wait whose watchable arc is a slow drift-into-sync rather than a frequent discrete stop-and-check.

Install

npx shadcn add https://design.helpmarq.com/r/bombe-drum-halt.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

An ambient search/lookup loading indicator sourced from the electromechanical Bombe's drum-scanning search cycle: banks of rotating drums (each a candidate rotor position) spin continuously while a sensing relay bank tests each position as it passes, and when a candidate briefly satisfies the test the whole bank locks dead for a beat while a diagonal-board-style check runs, then releases and resumes — almost every stop a false positive. Renders four vertical drum columns side by side (DRUM_COUNT = 4), each an overflow:hidden window over an absolutely-positioned strip of explicit tick-mark `<div>`s (not a CSS repeating-linear-gradient) built as a 26-position revolution — one per rotor letter, historically the right count for a Bombe drum — with position 0 a distinct brighter/taller 'datum' mark, every 5th position a heavier mark, and the rest plain, repeated back-to-back enough times to cover the drum window across a full modulo sweep. This non-uniform 26-tick revolution is deliberate: a uniform 1-tick-period gradient (the first implementation) aliases back to a visually identical state every single tick — about every 78ms at the default spacing — so a t0/2.5s/5s screenshot triple is indistinguishable whenever no halt happens to be mid-flight; the 26-tick revolution (~stripLength/180px/s, on the order of 1-2s depending on card size) makes a drum's resting phase legible and non-repeating across the whole 2.5s/5s sampling window even with zero halts landing in it. Column separators are 1px var(--border) dividers between drums (never a fill). Geometry is derived from the container's smaller dimension via a ResizeObserver on the root: drum width = smaller/9, drum height = smaller*0.85, and tick spacing itself scales with drum height (tickPeriod = max(4px, drumHeight/45)) so the strip's density — not just the drum's outer box — reads correctly at card scale. All four drums are driven by ONE shared requestAnimationFrame clock rather than four independent CSS animation timelines or a CSS `animation: infinite`, specifically so a halt can pause that one clock precisely instead of fighting four separately-running loops back into alignment: a single `phase` state machine cycles scan -> decel -> hold -> reaccel -> scan, and every drum's per-frame position delta is `SCROLL_SPEED_PX_S (180) * speedFactor(phase) * dt / 1000`, applied identically to all four so they lock and release in unison. 180px/s is a rendered rate, not the historical one — the real Bombe tested on the order of hundreds of rotor-hypotheses/s, far above what could read as anything but noise on a 60Hz screen, so the visible scroll speed is decoupled from that real rate the same way round-9's rate-decoupling rule decoupled a strobing 240Hz row-scan into a slow legible sweep; at the default tick spacing 180px/s reads as roughly 13 tick-marks/s of smooth, blur-free motion. The 3-4 drum phase stagger (drum N reads N*0.6s 'ahead' of drum 0) is implemented as nothing more than a different seed offset added to each drum's accumulated position at mount — not an independent timer — so the columns never lock-step visually despite sharing one clock. Each drum's on-screen transform is `translateY(-(position mod stripLength))`, applied via a ref-held style mutation every frame (not React state) to stay off the render path. A halt is scheduled 4-6s out, re-rolled fresh after every resume (`HALT_MIN_S=4, HALT_MAX_S=6`, uniform random — never a fixed metronome). On entering decel, speed eases 1 -> 0 over 180ms via a cubic ease-in-out while a 1px-tall sensing bar spanning all four drums (positioned at 42% of drum height, the fixed 'sensing bar' height) brightens linearly from its idle var(--foreground) 20% opacity toward 90%; hold then freezes speed at exactly 0 for 900ms at the full 90% bar opacity (the 'check running' beat, the single longest and most legible sub-phase); reaccel eases speed 0 -> 1 back over 220ms as the bar dims back to 20%. The full halt event totals roughly 1.3s (180+900+220ms), comfortably long enough to register as three distinct beats — stop, hold, release — rather than a blink. On exactly 15% of resumes (rolled once, at the moment reaccel completes and a fresh halt is scheduled) a single randomly-chosen drum column is given a 40%-opacity outline ring at the same 42%-height sensing line — read as the mark currently under test, since that is where the check runs — that fades back to 0 opacity 600ms later via a plain CSS opacity transition — a rarer 'this one got a second look' variant that still resolves and never latches into a permanent state. Nothing here is a real search result or a readout; there is no click-to-force-a-halt and no percentage, by design, since either would imply an actual result being represented. Under prefers-reduced-motion the rAF loop never starts at all: the component renders directly into the single most-structured static frame — every drum's transform set once to its phase-staggered rest offset, and the sensing bar's opacity set once to 90% (the peak-brightness mid-hold instant, not a mid-scroll blur) — via the exact same style-mutation helpers the animated path uses, just called once instead of every frame. An IntersectionObserver pauses the driving clock's elapsed-time accounting (not the rAF calls themselves) while the root scrolls offscreen; on return it resyncs the time base and, if a halt was mid-flight when visibility was lost, treats that stale halt as invalidated (rather than resolving three sub-phases in one jumped frame) — it snaps back to scan, dims the sensing bar back to idle, clears any ring, and reschedules the next halt fresh, so scrolling away and back never produces a burst of catch-up motion or a glitchy multi-phase jump. Cleanup on unmount cancels the rAF handle and disconnects the ResizeObserver and IntersectionObserver. Every colour is a CSS custom property referenced directly (var(--foreground), var(--border)) — no colour literals, no accent (this is an ambient loader, not interaction chrome, so var(--ns-accent) never appears). Props: label (accessible status text, default 'Searching', applied as aria-label on the root's role=status wrapper — the drum grid itself carries no other ARIA since it is purely decorative motion), className. Pure DOM + CSS + requestAnimationFrame, zero dependencies.

Props

PropTypeDefaultDescription
label?string"Searching"accessible label for the ambient search/loading status
className?string—extra classes merged onto the rendered root element