ns-ui / loud
Arc Ladder Climb
A full-bleed section-divider background modelled on a Jacob's ladder: two rails diverge from a narrow base gap, a bright plasma arc strikes there, climbs the widening gap as it burns, dies near the top, and a fresh arc restrikes at the base almost immediately. This runs continuously, at a re-randomized ~1.6-2.2s cadence.
Use when a full-bleed break between two page sections that should stay visibly alive rather than sit as a static rule — the mechanism is a single discrete strike-climb-extinguish-restrike cycle on diverging rails, always re-striking at the same narrow base point, never a continuous glow or a cursor-driven effect. Pick plasma-filament-wander instead when the identity should be several independent filaments simultaneously reaching from a shared center toward a ring, each rerouting on its own staggered clock, or border-electric-arc when the crackle belongs on a CTA's own border as interaction chrome rather than as a full-bleed section background.
Install
npx shadcn add https://design.helpmarq.com /r/arc-ladder-climb.jsonSource
registry/loud/arc-ladder-climb/component.tsx"use client";
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
// ---------------------------------------------------------------------------
// ArcLadderClimb — a full-bleed section-divider background modelled on a
// Jacob's ladder: two bare rails diverge upward at a fixed angle from a
// narrow gap at the bottom-center. The field is always strongest at that
// gap, so an arc always strikes there first; buoyancy plus the widening
// rail geometry drags it upward, and the climbing gap eventually exceeds
// what the "supply" can sustain, so the arc dies and a fresh one restrikes
// at the base almost immediately. One continuous rAF loop drives a single
// arc through a climbing/dark phase machine — never a per-frame random
// reset — so the SAME cycle logic used for the live frame also produces the
// reduced-motion freeze and survives a background tab's dropped frames by
// catching up through however many completed phases elapsed, exactly like
// plasma-filament-wander's advanceFilament.
//
// The arc's own polyline jitter is resampled from a hashed pseudo-random
// function keyed on a 24Hz time-bucket (~41.7ms), not the 60Hz paint clock
// — the round-9 aliasing lesson: a real HV arc flickers near mains
// frequency, so it is deliberately decoupled from 1:1 frame-rate rendering
// rather than driving the redraw off it. A short trail of 2-3 previously
// sampled climb heights is kept alongside the live arc and drawn underneath
// it at falling opacity, reading as heated air rather than a static rung.
// ---------------------------------------------------------------------------
const HALF_ANGLE_DEG = 7;
const HALF_ANGLE_TAN = Math.tan((HALF_ANGLE_DEG * Math.PI) / 180);
const BASE_GAP_FRAC = 0.014; // of minDim
const CLIMB_START_PX = 4; // scaled
const CLIMB_MAX_FRAC = 0.85; // of container height
const CLIMB_EASE = 2; // easeInQuad
const PERIOD_MIN_MS = 1600;
const PERIOD_MAX_MS = 2200;
const CLIMB_FRAC_OF_PERIOD = 0.78;
const DARK_MIN_MS = 120;
const DARK_MAX_MS = 220;
const JITTER_BUCKET_MS = 1000 / 24; // 24Hz, decoupled from the 60Hz paint loop
const JITTER_MIN_PX = 3;
const JITTER_MAX_PX = 7;
const VERTS_MIN = 5;
const VERTS_MAX = 7;
const TRAIL_SAMPLE_MS = 130;
const TRAIL_MAX = 3;
const TRAIL_FADE_MS = 400;
const TRAIL_STEP_ALPHA = 0.55; // per trail segment
const CORE_WIDTH_MIN_PX = 2;
const CORE_WIDTH_MAX_PX = 3;
const HALO_BLUR_PX = 6;
const REF_DIM = 640;
const SCALE_MIN = 0.6;
const SCALE_MAX = 1.6;
const STATIC_FREEZE_FRAC = 0.55; // reduced-motion: 55% climb height
interface Tokens {
fg: string;
muted: string;
}
function readTokens(): Tokens | null {
if (typeof document === "undefined") return null;
const cs = getComputedStyle(document.documentElement);
const fg = cs.getPropertyValue("--foreground").trim();
const muted = cs.getPropertyValue("--ns-muted").trim();
if (!fg || !muted) return null; // not loaded yet — no paint before this
return { fg, muted };
}
function mulberry32(seed: number) {
let a = seed >>> 0;
return () => {
a = (a + 0x6d2b79f5) >>> 0;
let t = a;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** deterministic hashed pseudo-random in [-1, 1], keyed on rank + time-bucket + vertex —
* gives a discretely-resampled jitter with no persisted per-frame state. */
function hashJitter(rank: number, bucket: number, vertex: number): number {
let h = Math.imul((rank + 1) * 0x9e3779b9, 0x85ebca6b);
h ^= Math.imul((bucket + vertex * 7349) ^ h, 0xc2b2ae35);
h ^= h >>> 16;
return ((h >>> 0) / 4294967296) * 2 - 1;
}
function easeInQuad(t: number): number {
const c = Math.max(0, Math.min(1, t));
return c * c;
}
interface Geo {
W: number;
H: number;
minDim: number;
cx: number;
scale: number;
baseGapHalf: number;
maxClimb: number;
}
function computeGeo(W: number, H: number): Geo {
const minDim = Math.min(W, H);
const scale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, minDim / REF_DIM));
return {
W,
H,
minDim,
cx: W / 2,
scale,
baseGapHalf: (minDim * BASE_GAP_FRAC) / 2,
maxClimb: H * CLIMB_MAX_FRAC,
};
}
/** x-offset from center at a given climb height (distance risen from the base) */
function railOffsetAt(geo: Geo, ch: number): number {
return geo.baseGapHalf + ch * HALF_ANGLE_TAN;
}
interface ArcState {
phase: "climbing" | "dark";
phaseStartMs: number;
phaseDurMs: number;
vertCount: number;
rank: number; // varies the jitter hash between successive arcs
trail: { ch: number; capturedAt: number }[];
lastTrailSampleMs: number;
}
function randPeriod(rand: () => number) {
return PERIOD_MIN_MS + rand() * (PERIOD_MAX_MS - PERIOD_MIN_MS);
}
function randDark(rand: () => number) {
return DARK_MIN_MS + rand() * (DARK_MAX_MS - DARK_MIN_MS);
}
function randVerts(rand: () => number) {
return VERTS_MIN + Math.floor(rand() * (VERTS_MAX - VERTS_MIN + 1));
}
function buildArcState(rand: () => number): ArcState {
return {
phase: "climbing",
phaseStartMs: 0,
phaseDurMs: randPeriod(rand) * CLIMB_FRAC_OF_PERIOD,
vertCount: randVerts(rand),
rank: 0,
trail: [],
lastTrailSampleMs: -Infinity,
};
}
/** walks the arc's climbing/dark phase machine forward to nowMs, catching up
* through as many completed phases as have elapsed — a fresh climb duration
* or dark gap is only ever rolled at the instant the arc's OWN phase ends. */
function advanceArc(state: ArcState, nowMs: number, rand: () => number) {
for (let guard = 0; guard < 64; guard++) {
const phaseEndMs = state.phaseStartMs + state.phaseDurMs;
if (nowMs < phaseEndMs) return;
if (state.phase === "climbing") {
state.phase = "dark";
state.phaseStartMs = phaseEndMs;
state.phaseDurMs = randDark(rand);
} else {
state.phase = "climbing";
state.phaseStartMs = phaseEndMs;
state.phaseDurMs = randPeriod(rand) * CLIMB_FRAC_OF_PERIOD;
state.vertCount = randVerts(rand);
state.rank += 1;
}
}
}
function climbHeightAt(geo: Geo, t: number): number {
const eased = easeInQuad(t);
return CLIMB_START_PX * geo.scale + (geo.maxClimb - CLIMB_START_PX * geo.scale) * eased;
}
function drawRails(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens) {
const topOffset = railOffsetAt(geo, geo.H);
const baseOffset = geo.baseGapHalf;
ctx.save();
ctx.strokeStyle = tokens.muted;
ctx.lineWidth = 1.25 * geo.scale;
ctx.lineCap = "round";
ctx.globalAlpha = 0.8;
ctx.beginPath();
ctx.moveTo(geo.cx - baseOffset, geo.H);
ctx.lineTo(geo.cx - topOffset, 0);
ctx.moveTo(geo.cx + baseOffset, geo.H);
ctx.lineTo(geo.cx + topOffset, 0);
ctx.stroke();
ctx.restore();
}
function arcVertices(geo: Geo, ch: number, vertCount: number, rank: number, bucket: number, jittering: boolean) {
const y = geo.H - ch;
const halfSpan = railOffsetAt(geo, ch);
const leftX = geo.cx - halfSpan;
const rightX = geo.cx + halfSpan;
const jitterMag = (JITTER_MIN_PX + ((rank * 0.61803) % 1) * (JITTER_MAX_PX - JITTER_MIN_PX)) * geo.scale;
const pts: { x: number; y: number }[] = [];
for (let i = 0; i < vertCount; i++) {
const t = i / (vertCount - 1);
const x = leftX + (rightX - leftX) * t;
if (!jittering || i === 0 || i === vertCount - 1) {
pts.push({ x, y });
continue;
}
const taper = Math.sin(Math.PI * t); // pinned at both rail contact points
const j = hashJitter(rank, bucket, i) * jitterMag * taper;
pts.push({ x, y: y + j });
}
return pts;
}
function drawArcPath(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens, pts: { x: number; y: number }[], alpha: number, withHalo: boolean) {
const first = pts[0];
if (!first) return;
ctx.save();
ctx.lineJoin = "round";
ctx.lineCap = "round";
ctx.strokeStyle = tokens.fg;
ctx.globalAlpha = alpha;
if (withHalo) {
ctx.shadowColor = tokens.fg;
ctx.shadowBlur = HALO_BLUR_PX * geo.scale;
}
ctx.lineWidth = (CORE_WIDTH_MIN_PX + ((geo.scale + 0.37) % 1) * (CORE_WIDTH_MAX_PX - CORE_WIDTH_MIN_PX)) * geo.scale;
ctx.beginPath();
ctx.moveTo(first.x, first.y);
for (let i = 1; i < pts.length; i++) {
const p = pts[i];
if (p) ctx.lineTo(p.x, p.y);
}
ctx.stroke();
ctx.restore();
}
export interface ArcLadderClimbProps {
/** content rendered over the field (headline, section label, etc.) */
children?: ReactNode;
/** extra classes merged onto the rendered root element */
className?: string;
}
export function ArcLadderClimb({ children, className = "" }: ArcLadderClimbProps) {
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;
const seed = 0x4c1b7a;
const rand = mulberry32(seed ^ 0x2545f4);
let disposed = false;
let tokens: Tokens | null = null;
let dpr = 1;
let geo: Geo = computeGeo(1, 1);
let sized = false;
let visible = true;
let state: ArcState = buildArcState(rand);
let startMs = 0;
let raf = 0;
let tokenWaitRaf = 0;
const fitCanvas = () => {
canvas.width = Math.max(1, Math.round(geo.W * dpr));
canvas.height = Math.max(1, Math.round(geo.H * dpr));
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
const render = (nowMs: number) => {
if (!tokens || !sized) return;
advanceArc(state, nowMs, rand);
ctx.clearRect(0, 0, geo.W, geo.H);
drawRails(ctx, geo, tokens);
const bucket = Math.floor(nowMs / JITTER_BUCKET_MS);
const climbing = state.phase === "climbing";
const elapsed = nowMs - state.phaseStartMs;
const liveCh = climbing ? climbHeightAt(geo, elapsed / state.phaseDurMs) : geo.maxClimb;
if (climbing && nowMs - state.lastTrailSampleMs >= TRAIL_SAMPLE_MS) {
state.trail.push({ ch: liveCh, capturedAt: nowMs });
if (state.trail.length > TRAIL_MAX) state.trail.shift();
state.lastTrailSampleMs = nowMs;
}
// afterglow trail: older samples first, so the live arc paints last (on top)
for (let i = 0; i < state.trail.length; i++) {
const s = state.trail[i];
if (!s) continue;
const age = nowMs - s.capturedAt;
if (age >= TRAIL_FADE_MS || age < 0) continue;
const ageAlpha = 1 - age / TRAIL_FADE_MS;
const rankAlpha = Math.pow(TRAIL_STEP_ALPHA, state.trail.length - i);
const pts = arcVertices(geo, s.ch, state.vertCount, state.rank, bucket, true);
drawArcPath(ctx, geo, tokens, pts, ageAlpha * rankAlpha * 0.9, false);
}
if (climbing) {
const pts = arcVertices(geo, liveCh, state.vertCount, state.rank, bucket, true);
drawArcPath(ctx, geo, tokens, pts, 0.95, true);
}
};
const resizeAll = () => {
if (!tokens) return;
const rect = root.getBoundingClientRect();
if (rect.width < 4 || rect.height < 4) {
sized = false;
return;
}
dpr = Math.min(window.devicePixelRatio || 1, 2);
geo = computeGeo(rect.width, rect.height);
fitCanvas();
sized = true;
};
const loop = (nowRaf: number) => {
if (disposed) return;
if (!visible) {
raf = 0; // IntersectionObserver re-arms this on re-entering view
return;
}
raf = requestAnimationFrame(loop);
if (!sized || !tokens) return;
if (startMs === 0) startMs = nowRaf;
render(nowRaf - startMs);
};
const buildReducedFrame = () => {
if (!tokens || !sized) return;
ctx.clearRect(0, 0, geo.W, geo.H);
drawRails(ctx, geo, tokens);
const ch = geo.maxClimb * STATIC_FREEZE_FRAC;
const vertCount = VERTS_MIN + Math.floor((VERTS_MAX - VERTS_MIN) / 2);
// static afterglow: two fixed steps below the frozen arc, no timers —
// jitter uses a fixed bucket (0) so the shape is deterministic and
// never redrawn, but still visibly jagged rather than a flat line.
const ghost1 = arcVertices(geo, ch * 0.86, vertCount, 1, 0, true);
const ghost2 = arcVertices(geo, ch * 0.7, vertCount, 2, 0, true);
drawArcPath(ctx, geo, tokens, ghost2, 0.16, false);
drawArcPath(ctx, geo, tokens, ghost1, 0.32, false);
const live = arcVertices(geo, ch, vertCount, 0, 0, true);
drawArcPath(ctx, geo, tokens, live, 0.95, true);
};
let started = false;
const kick = () => {
if (started || disposed || !tokens || !sized) return;
started = true;
if (reduced) {
buildReducedFrame();
return; // no rAF loop, no timers, no observers driving motion
}
raf = requestAnimationFrame(loop);
};
const start = () => {
if (disposed) return;
tokens = readTokens();
if (!tokens) {
tokenWaitRaf = requestAnimationFrame(start);
return;
}
resizeAll();
kick();
};
const ro = new ResizeObserver(() => {
if (!tokens) return;
resizeAll();
if (reduced) buildReducedFrame();
kick();
});
ro.observe(root);
const mo = new MutationObserver(() => {
tokens = readTokens();
if (tokens) {
resizeAll();
if (reduced) buildReducedFrame();
kick();
}
});
mo.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
const io = new IntersectionObserver((entries) => {
const wasVisible = visible;
visible = entries[0]?.isIntersecting ?? true;
if (visible && !wasVisible && !reduced && tokens && !raf) {
tokens = readTokens() ?? tokens; // pick up any theme flip that happened while hidden
resizeAll();
raf = requestAnimationFrame(loop);
}
});
io.observe(root);
start();
return () => {
disposed = true;
cancelAnimationFrame(raf);
cancelAnimationFrame(tokenWaitRaf);
ro.disconnect();
mo.disconnect();
io.disconnect();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<section ref={rootRef} className={`relative isolate min-h-[420px] w-full overflow-hidden bg-background ${className}`}>
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute inset-0 h-full w-full" />
{children ? (
<div className="relative z-10 mx-auto flex h-full min-h-[420px] w-full max-w-5xl flex-col items-center justify-center gap-3 px-6 py-16 text-center">
{children}
</div>
) : null}
</section>
);
}
Build spec
Build <ArcLadderClimb children? className?> as a full-bleed Canvas 2D section-divider background modelling a Jacob's ladder. GEOMETRY: computeGeo(W, H) derives baseGapHalf = (minDim * 0.014) / 2 and maxClimb = H * 0.85 from the container's own dimensions, plus a scale factor clamp(minDim / 640, 0.6, 1.6) applied to stroke widths, jitter magnitude and halo blur so the same numbers read from a slim divider strip up to a 100vw section break. Two straight rails are stroked once per frame in --ns-muted at 0.8 alpha from (cx -+ baseGapHalf, H) at the bottom up to (cx -+ railOffsetAt(H), 0) at the top, where railOffsetAt(ch) = baseGapHalf + ch * tan(7deg) — a fixed 7-degree half-angle divergence, so the gap between the rails at any climb height is purely a function of how far the arc has risen from the base. STATE MACHINE: a single ArcState carries phase ('climbing' | 'dark'), phaseStartMs, phaseDurMs, a per-climb random vertex count (5-7) and an incrementing rank used to vary the jitter hash between successive arcs; advanceArc(state, nowMs, rand) is called every frame and walks the phase machine forward through however many completed phases fall between the last check and now (a for-loop with a 64-iteration guard, not a single if) — the same idiom plasma-filament-wander uses for advanceFilament, letting a background tab or the reduced-motion freeze jump to an arbitrary elapsed time without ever appearing to teleport. TIMING: each climb draws a fresh period uniformly from 1600-2200ms (re-randomized every cycle, never a fixed metronome) and climbs for 78% of that period, eased via easeInQuad from a climb height of 4px*scale up to maxClimb (0.85 * container height) — real ladders accelerate as the buoyant plume speeds up, so the climb visibly speeds up toward the top rather than gliding at constant rate; on completing its climb the phase flips to 'dark' for a freshly-rolled 120-220ms (ionization redeveloping before the next strike, never instantaneous), after which a brand new climb begins with a freshly-rolled period, vertex count and rank. JITTER: the arc itself is a polyline spanning the current left-rail to right-rail contact points at its live climb height, pinned exactly at both endpoints (the rail contact points), with interior vertices displaced vertically by hashJitter(rank, timeBucket, vertexIndex) * (3-7px scaled, derived per-rank from a golden-ratio hash so consecutive arcs get visibly different jitter magnitudes) * a sin(pi*t) taper so displacement is zero at the pinned rail contacts and freest at mid-span; timeBucket = floor(nowMs / (1000/24)), a fixed 24Hz resample rate deliberately decoupled from the 60Hz paint loop per the round-9 aliasing rule, so the redraw itself can stay at full frame rate while the perceived flicker sits at the real HV-arc-adjacent rate. AFTERGLOW: while climbing, the live climb height is pushed onto a max-3-entry trail array every ~130ms (capturedAt timestamp retained); every frame, every trail entry younger than 400ms is redrawn underneath the live arc at alpha = (1 - age/400ms) * 0.55^(distance-from-newest) with no halo, giving a decaying stack of previous positions that reads as heated air rather than a static rung — entries are never explicitly cleared on restrike, so a dying afterglow from the previous climb can legitimately still be fading near the top for its last ~100-200ms while a brand new arc is already striking at the base below it, matching the real device's overlap. RENDER: the live arc is stroked (round joins/caps) in --foreground at 0.95 alpha with a canvas shadowBlur of 6px*scale in the same --foreground colour for its halo — a pure luminance halo, --ns-accent never appears anywhere on the rails, arc or glow, and the arc's colour is always read live off --foreground (never a fixed value) so it keeps reading as the single brightest element in the frame in both themes. Colour is read once via getComputedStyle(document.documentElement) for --foreground and --ns-muted with zero literal fallback of any kind; if either is empty the mount loop retries on the next rAF and paints nothing until both resolve. A MutationObserver on document.documentElement's class attribute re-reads tokens on every theme flip and forces a redraw; a ResizeObserver on the root recomputes geometry and resizes the canvas backing store (devicePixelRatio capped at 2) without resetting the arc's in-flight phase; an IntersectionObserver stops driving new frames while off-screen and, on returning to view, re-reads tokens (picking up any theme flip that happened while hidden) and resumes the same continuous elapsed-time clock — no paint happens on any of these paths before a token read succeeds. Under prefers-reduced-motion the component runs zero rAF loops, zero timers: it renders exactly one deterministic frame — climb height frozen at 55% of maxClimb (clearly separated from both rails, elongated, neither the too-short t0 strike nor a near-top full climb) with two fixed-opacity static afterglow steps beneath it (0.32 and 0.16 alpha, deterministic jitter bucket 0, never re-rendered) — and never redraws. Cleanup on unmount cancels both the animation and token-wait rAF handles and disconnects all three observers. Optional children render over the field in a centered max-w-5xl column (a section label or short line, mirroring plasma-filament-wander's overlay slot), with pointer-events left to the canvas's aria-hidden sibling so nothing intercepts clicks meant for real content — there is no pointer tracking or hover state anywhere in this component, the climb-and-restrike cadence is the entire spectacle. Zero dependencies, DOM+canvas only, no colour literal anywhere including fallbacks.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| children? | ReactNode | — | content rendered over the field (headline, section label, etc.) |
| className? | string | — | extra classes merged onto the rendered root element |