Skip to main content

ns-ui / loud

Mailbag Hook Exchange

A full-bleed ambient background reproducing the Railway Post Office catcher-crane mail exchange: a trackside crane holds an outgoing mailbag on a hook, swaying between passes, while a train sweeps through on a fixed cadence, its catcher arm snagging the bag and kicking a replacement onto the trackside net without ever stopping.

Use when a full-bleed decorative hero/section background with one clear periodic handoff event and continuous idle motion between events, distinct from background-engine-turn-guilloche (a dense woven interference field with no discrete events at all) and sorter-pocket-route (a card-scale loader whose subject is routing into pockets, not a single cyclic exchange) — pick this when the surface wants a slow-building, cinematic 'something arrives, something is exchanged' rhythm rather than continuous ambient texture.

Install

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

import { useEffect, useRef } from "react";
import type { ReactNode } from "react";

// ---------------------------------------------------------------------------
// MailbagHookExchange — a full-bleed ambient background reproducing the
// Railway Post Office (RPO) catcher-crane mail exchange (US mail trains,
// roughly 1864-1977): a trackside crane holds an outgoing mailbag on an iron
// hook; a passing train's catcher arm snags it and kicks a replacement bag
// onto the trackside net in the same motion, without the train ever
// stopping. Real engagement was well under a second and real headway
// between exchanges was minutes to hours — both are DECOUPLED here for
// legibility, not animated 1:1 (see round 9's meter-matrix-scan lesson):
// the exchange gesture is stretched to a scripted ~900ms sequence and the
// wait between exchanges is compressed to a fixed 7.0s cadence so the wait
// itself reads as anticipation rather than as a broken/idle component.
//
// ONE GOVERNING CLOCK. Everything derives from a single elapsed-seconds
// scalar `t`. Train position, arm gesture phase, and completed-exchange
// count are all pure functions of `t % CYCLE` (or, for the exchange count,
// how many CONTACT instants have passed) — there is no separate per-element
// timer to drift out of sync.
//
// ALIVE AT REST, between exchanges: the crane arm is a damped pendulum that
// never fully stops — each exchange's kick leaves it swinging at PEAK_DEG,
// decaying (tau ~0.9s) toward a PENDULUM_FLOOR_DEG resting tremor it never
// drops below, at PENDULUM_PERIOD (1.6s). Independently, a signal lamp on
// the crane post blinks at 0.5Hz (2s period) continuously, on its own
// clock — with CYCLE=7s and LAMP_PERIOD=2s the two motions' phase pairing
// at any given t0 only repeats every lcm(7,2)=14s, so back-to-back 5s
// screenshots essentially never look identical. Rail ties scroll left at a
// constant rate at all times, whether or not a train is on screen.
//
// THE EXCHANGE. CONTACT_T (derived below from the crane's x-fraction, the
// train's length fraction, and the crossing span/duration, not hardcoded)
// is the instant the train's marked engagement point reaches the crane.
// The gesture around it: 280ms swing-out (rest angle -> extended angle,
// ease-in-out), 120ms hold (hook engaged — this is the freeze-frame
// moment), 500ms swing-back (extended -> rest, ease-in-out). At the hold's
// midpoint the held-bag pattern flips (a fixed alternation, not per-frame
// randomness) and a small kicked-bag glyph streaks from the engagement
// point down into the trackside net, fading over the swing-back window —
// the visible "something left, something arrived" beat.
// ---------------------------------------------------------------------------

const CYCLE = 7.0; // seconds between exchanges — fixed cadence, not jitter
const CROSS_DURATION = 2.6; // seconds the train spends fully sweeping the frame
const ENTER_FRAC = -0.2; // train's leading edge starts here (fraction of width)
const SPAN_FRAC = 1.4; // total leading-edge travel across the crossing (fraction of width)
const CRANE_X_FRAC = 0.62; // crane position, fraction of width
const TRAIN_LEN_FRAC = 0.26; // train body length, fraction of width
const ENGAGE_OFFSET_FRAC = 0.4; // engagement marker's offset back from the train's leading edge, as a fraction of train length

// the instant the train's engagement marker aligns with the crane, derived
// (not guessed) from the geometry constants above so gesture timing and
// visual alignment can never drift apart.
const CONTACT_T =
  (CROSS_DURATION *
    (CRANE_X_FRAC + TRAIN_LEN_FRAC * ENGAGE_OFFSET_FRAC - ENTER_FRAC)) /
  SPAN_FRAC;

const SWING_OUT = 0.28;
const HOLD = 0.12;
const SWING_BACK = 0.5;
const GESTURE_START = CONTACT_T - SWING_OUT;
const GESTURE_END = GESTURE_START + SWING_OUT + HOLD + SWING_BACK;

const REST_DEG = 0; // arm hangs straight down at rest
const EXTENDED_DEG = 34; // arm swung out toward the oncoming train
const PENDULUM_PERIOD = 1.6; // seconds, matches the spec's idle-sway rate
const PENDULUM_OMEGA = (2 * Math.PI) / PENDULUM_PERIOD;
const PENDULUM_FLOOR_DEG = 2; // resting tremor amplitude, never fully still
const PENDULUM_PEAK_DEG = 18; // amplitude right after a kick
const PENDULUM_TAU = 0.9; // decay time constant, seconds

const LAMP_PERIOD = 2.0; // seconds, independent clock from CYCLE

const TIE_SPEED = 40; // px/s, constant regardless of train visibility

// the freeze frame prefers-reduced-motion lands on: mid-HOLD, arm fully
// extended, hook engaged, train mid-crossing — the single most structured
// instant the loop produces (crane, train, and both bags legible at once).
const CONTACT_FRAME = GESTURE_START + SWING_OUT + HOLD / 2;

function easeInOut(x: number): number {
  const c = x < 0 ? 0 : x > 1 ? 1 : x;
  return c * c * (3 - 2 * c);
}

/** Arm angle in degrees at gesture-relative time `g` (seconds since GESTURE_START, may be negative). */
function gestureAngle(g: number): number {
  if (g < 0 || g > SWING_OUT + HOLD + SWING_BACK) return REST_DEG;
  if (g < SWING_OUT) return REST_DEG + (EXTENDED_DEG - REST_DEG) * easeInOut(g / SWING_OUT);
  if (g < SWING_OUT + HOLD) return EXTENDED_DEG;
  const back = g - SWING_OUT - HOLD;
  return EXTENDED_DEG + (REST_DEG - EXTENDED_DEG) * easeInOut(back / SWING_BACK);
}

/** Idle pendulum tremor (degrees) added on top of the gesture angle, given seconds since the last gesture ended. */
function pendulumTremor(sincePass: number): number {
  const s = Math.max(0, sincePass);
  const amp = PENDULUM_FLOOR_DEG + (PENDULUM_PEAK_DEG - PENDULUM_FLOOR_DEG) * Math.exp(-s / PENDULUM_TAU);
  return amp * Math.sin(PENDULUM_OMEGA * s);
}

export interface MailbagHookExchangeProps {
  /** headline / CTA rendered above the field */
  children?: ReactNode;
  /** extra classes merged onto the rendered root element */
  className?: string;
  /** inline styles merged onto the root element */
  style?: React.CSSProperties;
}

export function MailbagHookExchange({ children, className = "", style }: MailbagHookExchangeProps) {
  const rootRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);

  useEffect(() => {
    const root = rootRef.current;
    const canvas = canvasRef.current;
    if (!root || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

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

    // token fields start empty; draw() below refuses to paint until
    // readTokens() has run at least once — closes rAF, resize, and the
    // reduced-motion branch as paint-before-read paths.
    let fgColor = "";
    let mutedColor = "";
    let bgColor = "";
    const readTokens = () => {
      const cs = getComputedStyle(document.documentElement);
      fgColor = cs.getPropertyValue("--foreground").trim();
      mutedColor = cs.getPropertyValue("--ns-muted").trim();
      bgColor = cs.getPropertyValue("--background").trim();
    };

    let w = 0;
    let h = 0;
    let sized = false;
    let visible = true;
    let raf = 0;
    let t = reduced ? CONTACT_FRAME : 0;
    let last = 0;

    const resize = () => {
      const rect = root.getBoundingClientRect();
      if (rect.width < 2 || rect.height < 2) {
        sized = false;
        return;
      }
      const isCard = !!canvas.closest("[data-autoplay-root]");
      const dpr = isCard
        ? Math.min(0.6, window.devicePixelRatio || 1)
        : Math.min(window.devicePixelRatio || 1, 1.5);
      w = rect.width;
      h = rect.height;
      canvas.width = Math.max(1, Math.round(w * dpr));
      canvas.height = Math.max(1, Math.round(h * dpr));
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      sized = true;
    };

    const draw = () => {
      if (!sized || !fgColor) return;
      ctx.clearRect(0, 0, w, h);

      const trackY = h * 0.74;
      const craneX = w * CRANE_X_FRAC;
      const craneBaseY = trackY;
      const craneHeight = Math.min(w, h) * 0.22;
      const craneTopY = craneBaseY - craneHeight;
      const armLength = Math.min(w, h) * 0.13;
      const bagSize = Math.min(w, h) * 0.045;
      const trainLength = w * TRAIN_LEN_FRAC;
      const trainHeight = Math.min(w, h) * 0.09;
      const tieSpacing = Math.max(10, Math.min(w, h) * 0.05);

      // --- rail + scrolling ties (structure, low emphasis, drawn always) ---
      ctx.strokeStyle = mutedColor;
      ctx.lineWidth = 1;
      ctx.beginPath();
      ctx.moveTo(0, trackY);
      ctx.lineTo(w, trackY);
      ctx.stroke();

      const tieOffset = (t * TIE_SPEED) % tieSpacing;
      ctx.lineWidth = 2;
      for (let x = -tieOffset; x < w + tieSpacing; x += tieSpacing) {
        ctx.beginPath();
        ctx.moveTo(x, trackY - 3);
        ctx.lineTo(x, trackY + 6);
        ctx.stroke();
      }

      // --- crane post + net (structure) ---
      ctx.strokeStyle = mutedColor;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(craneX, craneBaseY);
      ctx.lineTo(craneX, craneTopY);
      ctx.stroke();

      const netY = craneBaseY - craneHeight * 0.12;
      const netX = craneX - armLength * 0.55;
      ctx.beginPath();
      ctx.arc(netX, netY, bagSize * 0.9, Math.PI * 0.15, Math.PI * 0.85);
      ctx.stroke();

      // --- signal lamp (independent 0.5Hz clock, luminance only) ---
      const lampOn = Math.floor(t / (LAMP_PERIOD / 2)) % 2 === 0;
      ctx.fillStyle = lampOn ? fgColor : mutedColor;
      ctx.beginPath();
      ctx.arc(craneX, craneTopY - bagSize * 0.6, bagSize * 0.35, 0, Math.PI * 2);
      ctx.fill();

      // --- arm + hook + held bag (subject, brighter emphasis) ---
      const g = t - GESTURE_START - Math.floor((t - GESTURE_START) / CYCLE) * CYCLE;
      // g is "seconds since this cycle's GESTURE_START", wrapped into [0, CYCLE)
      const inGesture = g >= 0 && g <= SWING_OUT + HOLD + SWING_BACK;
      const armDeg = inGesture ? gestureAngle(g) : REST_DEG;
      const sincePass = inGesture ? 0 : g > SWING_OUT + HOLD + SWING_BACK ? g - (SWING_OUT + HOLD + SWING_BACK) : g + CYCLE - (SWING_OUT + HOLD + SWING_BACK);
      const tremor = inGesture ? 0 : pendulumTremor(sincePass);
      const angleRad = ((armDeg + tremor) * Math.PI) / 180;

      // arm pivots at the crane top; hooked bag hangs `armLength` from the
      // pivot at angle `angleRad` from straight-down (angle 0 = REST_DEG)
      const hookX = craneX + Math.sin(angleRad) * armLength;
      const hookY = craneTopY + Math.cos(angleRad) * armLength;

      ctx.strokeStyle = fgColor;
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.moveTo(craneX, craneTopY);
      ctx.lineTo(hookX, hookY);
      ctx.stroke();

      // completed-exchange count -> which bag pattern is currently held
      const completed = t >= CONTACT_T ? Math.floor((t - CONTACT_T) / CYCLE) + 1 : 0;
      const variant = completed % 2;

      ctx.fillStyle = fgColor;
      ctx.beginPath();
      ctx.roundRect(hookX - bagSize / 2, hookY, bagSize, bagSize * 1.15, bagSize * 0.2);
      ctx.fill();
      if (variant === 1) {
        // alternate fill pattern: a couple of thin horizontal bands, drawn
        // in the background color so it reads as a different sack, never a
        // hue change
        ctx.strokeStyle = bgColor;
        ctx.lineWidth = 1;
        for (let i = 1; i <= 2; i++) {
          const by = hookY + (bagSize * 1.15 * i) / 3;
          ctx.beginPath();
          ctx.moveTo(hookX - bagSize / 2 + 1, by);
          ctx.lineTo(hookX + bagSize / 2 - 1, by);
          ctx.stroke();
        }
      }

      // --- kicked bag streak toward the net, only during hold + swing-back ---
      if (inGesture && g >= SWING_OUT) {
        const kickG = g - SWING_OUT; // 0 at hold start, through HOLD+SWING_BACK
        const kickDur = HOLD + SWING_BACK;
        const kp = easeInOut(kickG / kickDur);
        const kx = hookX + (netX - hookX) * kp;
        const ky = hookY + (netY - hookY) * kp;
        ctx.globalAlpha = 1 - kp * 0.85;
        ctx.fillStyle = fgColor;
        ctx.beginPath();
        ctx.roundRect(kx - bagSize / 2.6, ky - bagSize * 0.5, bagSize * 0.77, bagSize * 0.9, bagSize * 0.15);
        ctx.fill();
        ctx.globalAlpha = 1;
      }

      // --- train silhouette, visible only within its crossing window ---
      const phase = t - Math.floor(t / CYCLE) * CYCLE;
      if (phase < CROSS_DURATION) {
        const progress = phase / CROSS_DURATION;
        const frontX = w * ENTER_FRAC + progress * w * SPAN_FRAC;
        const rearX = frontX - trainLength;
        const topY = trackY - trainHeight;

        ctx.fillStyle = fgColor;
        ctx.beginPath();
        ctx.moveTo(rearX, trackY);
        ctx.lineTo(rearX, topY);
        ctx.lineTo(frontX - trainLength * 0.12, topY);
        ctx.lineTo(frontX, trackY - trainHeight * 0.35);
        ctx.lineTo(frontX, trackY);
        ctx.closePath();
        ctx.fill();
      }
    };

    const loop = (now: number) => {
      const dt = last ? Math.min(0.05, (now - last) / 1000) : 1 / 60;
      last = now;
      t += dt;
      draw();
      if (visible && !document.hidden) raf = requestAnimationFrame(loop);
    };

    let resizeTimer: ReturnType<typeof setTimeout> | null = null;
    const ro = new ResizeObserver(() => {
      if (resizeTimer) clearTimeout(resizeTimer);
      resizeTimer = setTimeout(() => {
        resizeTimer = null;
        resize();
        if (reduced) draw();
      }, 120);
    });
    ro.observe(root);

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

    const onVis = () => {
      if (!document.hidden && visible && !reduced && !raf) {
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    document.addEventListener("visibilitychange", onVis);

    const mo = new MutationObserver(() => {
      readTokens();
      if (reduced || !raf) draw();
    });
    mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });

    readTokens();
    resize();

    if (reduced) {
      draw();
    } else {
      raf = requestAnimationFrame(loop);
    }

    return () => {
      cancelAnimationFrame(raf);
      if (resizeTimer) clearTimeout(resizeTimer);
      ro.disconnect();
      io.disconnect();
      mo.disconnect();
      document.removeEventListener("visibilitychange", onVis);
    };
  }, []);

  return (
    <div
      ref={rootRef}
      className={`relative isolate min-h-screen w-full overflow-hidden bg-background ${className}`}
      style={style}
    >
      <canvas
        ref={canvasRef}
        aria-hidden
        className="pointer-events-none absolute inset-0 block h-full w-full"
      />
      {children ? (
        <div className="relative z-10 flex min-h-screen w-full flex-col items-center justify-center gap-4 px-6 text-center">
          {children}
        </div>
      ) : null}
    </div>
  );
}
Build spec

Build <MailbagHookExchange className? style?><...headline/CTA/></MailbagHookExchange> as a full-bleed Canvas 2D background reproducing the Railway Post Office (RPO) catcher-crane mail exchange used on US mail trains roughly 1864-1977: a trackside crane holds an outgoing mailbag on an iron hook at a fixed point along the line; a passing train's catcher arm swings out, snags the bag, and in the same motion kicks a replacement bag onto the trackside net, all without the train stopping. ONE GOVERNING CLOCK: every visual (train position, arm gesture phase, held-bag pattern, pendulum tremor, lamp blink, tie scroll) is a pure function of a single elapsed-seconds scalar `t`, no independent per-element timers. CYCLE = 7.0s between exchanges (a fixed cadence, not random jitter). CROSS_DURATION = 2.6s is how long the train is visible, sweeping its leading edge linearly from ENTER_FRAC=-0.2 to ENTER_FRAC+SPAN_FRAC=1.2 (fractions of container width) over that window, train body length TRAIN_LEN_FRAC=0.26 of width. CONTACT_T, the instant the train's engagement marker (ENGAGE_OFFSET_FRAC=0.4 of train length back from the leading edge) reaches the crane at CRANE_X_FRAC=0.62 of width, is DERIVED algebraically from those fractions and CROSS_DURATION (CONTACT_T = CROSS_DURATION*(CRANE_X_FRAC + TRAIN_LEN_FRAC*ENGAGE_OFFSET_FRAC - ENTER_FRAC)/SPAN_FRAC, works out to ~1.716s) rather than hardcoded, so gesture timing and visual alignment cannot drift apart if any fraction is tuned later. THE GESTURE (real engagement is 0.15-0.3s, undrawable at 60Hz — this is a documented, deliberate stretch to 900ms for legibility, not a 1:1 animation of the real rate): starting at GESTURE_START = CONTACT_T - 0.28s, the arm swings from REST_DEG=0 (hanging straight down) to EXTENDED_DEG=34 over 280ms (ease-in-out), holds at EXTENDED_DEG for 120ms (hook engaged — this is the CONTACT_FRAME reduced-motion freezes on, at the hold's midpoint), then swings back to REST_DEG over 500ms (ease-in-out). Arm pivots at the crane top; the hooked-bag position is hookX = craneX + sin(angleRad)*armLength, hookY = craneTopY + cos(angleRad)*armLength, angleRad including a pendulum tremor added on top (see below) even during rest frames. HELD-BAG ALTERNATION: a monotonic completed-exchange counter (floor((t-CONTACT_T)/CYCLE)+1 once t >= CONTACT_T, else 0) modulo 2 selects which of two bag renders is on the hook — variant 1 adds two thin --background-colored bands across the bag so a swap is visibly a DIFFERENT bag, never the same glyph re-drawn. KICKED-BAG STREAK: during the hold+swing-back window (620ms), a second small bag glyph eases (easeInOut) from the hook position to a net glyph (a small arc) near the crane's base, fading via globalAlpha from 1 to ~0.15 as it travels — the visible 'something left, something arrived' beat the round 9 legibility rule requires, not a blink-cut. IDLE PENDULUM (proves Filter 2 between exchanges): outside any gesture window, the arm angle gets an added tremor = (PENDULUM_FLOOR_DEG=2 + (PENDULUM_PEAK_DEG=18 - 2)*exp(-sincePass/0.9)) * sin(2*PI/1.6 * sincePass), where sincePass is seconds since the last gesture ended (wrapped through the 7s cycle) — amplitude decays from a hard kick down to, and never below, a 2deg resting tremor at a 1.6s period. SIGNAL LAMP: independent clock, `Math.floor(t / (LAMP_PERIOD/2)) % 2 === 0` with LAMP_PERIOD=2.0s toggles a small circle at the crane top between --foreground (lit) and --ns-muted (unlit) — luminance only, this is the standing accent-tint check: never mix --ns-accent into the lamp or the kicked-bag streak. Because CYCLE=7 and LAMP_PERIOD=2 share no common factor smaller than lcm(7,2)=14s, the lamp/pendulum phase pairing at any t0 essentially never repeats within a normal viewing window. RAIL + TIES: a horizontal --ns-muted line at trackY = 0.74*height across the full width, with perpendicular tie marks spaced tieSpacing = max(10, min(width,height)*0.05) apart, scrolling continuously at TIE_SPEED=40px/s via `(t*40) % tieSpacing`, drawn regardless of whether a train is currently visible — this is what keeps the frame alive even mid-idle-cycle. GEOMETRY: craneHeight = min(w,h)*0.22, armLength = min(w,h)*0.13, bagSize = min(w,h)*0.045, trainHeight = min(w,h)*0.09 — all derived from the container's smaller dimension so the scene reads correctly at both hero and card scale; trainLength stays a fraction of WIDTH (0.26) since it's a horizontal object whose crossing needs to scale with how wide the frame actually is. TOKENS: --foreground for the train silhouette, the hooked/kicked bag fills, and the lit lamp state; --ns-muted for the rail, ties, crane post, net, and unlit lamp state; --background used only as the bag's alternate-pattern stroke color (so the bands read as cutouts, not a hue). All three read via getComputedStyle(document.documentElement) at mount and re-read on a MutationObserver watching documentElement's class; nothing paints before that first read (guarded in draw()). DPR-capped canvas backing store (1.5 full-bleed, 0.6 inside an autoplay preview card via the same `canvas.closest('[data-autoplay-root]')` check other loud backgrounds use), ResizeObserver-driven resize, IntersectionObserver (threshold 0) plus visibilitychange pausing the rAF loop off-viewport or tab-hidden. prefers-reduced-motion freezes the clock at CONTACT_FRAME (GESTURE_START + 0.28 + 0.06s, i.e. mid-hold) rather than t=0, because that instant is the single most structured frame the loop produces: train mid-crossing, arm fully extended, hook visibly engaged, kicked bag mid-flight toward the net. Decorative canvas is aria-hidden and pointer-events-none; there is no pointer interaction anywhere in this component — `children` render as real DOM in normal focus order above it. Props: children, className, style.

Props

PropTypeDefaultDescription
children?ReactNodeheadline / CTA rendered above the field
className?stringextra classes merged onto the rendered root element
style?React.CSSPropertiesinline styles merged onto the root element