Skip to main content

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.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/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

PropTypeDefaultDescription
children?ReactNodecontent rendered over the field (headline, section label, etc.)
className?stringextra classes merged onto the rendered root element