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.jsonSource
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
| Prop | Type | Default | Description |
|---|---|---|---|
| children? | ReactNode | — | headline / CTA rendered above the field |
| className? | string | — | extra classes merged onto the rendered root element |
| style? | React.CSSProperties | — | inline styles merged onto the root element |