Skip to main content

ns-ui / loud

Plasma Filament Wander

A full-bleed hero background modelled on a plasma globe: filaments reach from a center point to a faint outer ring, each retracting and regrowing toward a new point on its own staggered 1.2-2.6s cadence, with pointer proximity biasing where a filament next reroutes, never when.

Use when a full-bleed hero background wants a chaotic-but-legible, many-independent-threads identity where the eye can still follow one thread's own departure-and-arrival at a time — the mechanism is per-filament independent retract/regrow cycling on staggered clocks, pointer proximity only ever biasing a filament's own next reroute target, never its motion. Pick hero-ascii-lichtenberg instead when the hero should read as a single discrete branching-tree event that grows once, holds, and is replaced by the next strike, or border-electric-arc when the crackle belongs on a CTA's border rather than as a full-bleed backdrop.

Install

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

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

// ---------------------------------------------------------------------------
// PlasmaGlobe — a full-bleed hero background modelled on a plasma globe: a
// central electrode inside a low-pressure gas sphere ionizes discrete
// filaments that reach from the center to the inner glass, constantly
// wandering, retracting and reattaching elsewhere in a chaotic, ever-
// rerouting pattern. Each filament runs its OWN independent lifecycle —
// attached-and-jittering, or retracting-and-regrowing to a new target angle
// — on its own randomized 1.2-2.6s cadence, staggered so the globe always
// shows some motion while any single filament's own event stays trackable.
//
// A filament never teleports: advanceFilament() walks a filament's phase
// machine forward through however many completed cycles fall between the
// last frame and now (needed both for a background tab catching up and for
// jumping straight to the reduced-motion freeze frame), and a reroute is
// always the same 350ms eased retract-then-regrow, never a snap.
//
// Pointer proximity biases WHERE a filament reroutes to, never WHEN — the
// bias is only consulted at a filament's own natural reroute moment, and
// only ~40% of filaments take it, so the globe never reads as a spotlight
// chasing the cursor. Filament jitter is resampled from a hashed function of
// (filament, 100ms time-bucket) rather than persisted per-frame state, so a
// ~10Hz "plasma noise" cadence falls out for free without an extra timer.
// ---------------------------------------------------------------------------

const LIFETIME_MIN_MS = 1200;
const LIFETIME_MAX_MS = 2600;
const REROUTE_MS = 350; // eased retract-then-regrow transition

const JITTER_MIN_PX = 4;
const JITTER_MAX_PX = 8;
const JITTER_BUCKET_MS = 100; // ~10Hz resample, decoupled from the paint loop
const JITTER_SEGMENTS = 7; // interior points along a filament's polyline

const POINTER_PROXIMITY_FRAC = 0.2; // of the container's smaller dimension, measured from the ring
const POINTER_BIAS_MAX_FRAC = 0.4; // up to 40% of filaments take the bias at their own reroute
const POINTER_BIAS_SPREAD_RAD = 0.35; // randomized scatter around the pointer's angle

const CORE_WIDTH_MIN_PX = 1.5;
const CORE_WIDTH_MAX_PX = 2.5;
const HALO_BLUR_PX = 4;

const BASELINE_COUNT = 11;
const MIN_COUNT = 6;
const CARD_SCALE_REF = 520; // below this minDim, filament count thins to stay legible

const REF_DIM = 520; // width/jitter/radius scale reference
const SCALE_MIN = 0.55;
const SCALE_MAX = 1.6;

const RING_RADIUS_FRAC = 0.36; // of minDim
const RING_ALPHA = 0.35;

const STATIC_FREEZE_MS = 1800; // reduced-motion freeze: varied lengths, mid-cycle, asymmetric

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 filament + time-bucket + segment —
 * gives a discretely-resampled jitter with no persisted per-frame state. */
function hashJitter(filamentIdx: number, bucket: number, segment: number): number {
  let h = Math.imul(filamentIdx ^ 0x9e3779b9, 0x85ebca6b);
  h ^= Math.imul((bucket + segment * 7349) ^ h, 0xc2b2ae35);
  h ^= h >>> 16;
  return ((h >>> 0) / 4294967296) * 2 - 1;
}

function easeInOutCubic(x: number): number {
  const c = Math.max(0, Math.min(1, x));
  return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;
}

interface Geo {
  W: number;
  H: number;
  minDim: number;
  cx: number;
  cy: number;
  ringRadius: number;
  scale: number;
  count: number;
}

function computeGeo(W: number, H: number, baselineCount: number): Geo {
  const minDim = Math.min(W, H);
  const scale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, minDim / REF_DIM));
  const count = Math.max(MIN_COUNT, Math.min(baselineCount, Math.round(baselineCount * Math.min(1, minDim / CARD_SCALE_REF))));
  return {
    W,
    H,
    minDim,
    cx: W / 2,
    cy: H / 2,
    ringRadius: minDim * RING_RADIUS_FRAC,
    scale,
    count,
  };
}

interface Filament {
  phase: "attached" | "rerouting";
  angle: number; // current attached angle, or the retracting-from angle
  oldAngle: number;
  newAngle: number;
  phaseEndMs: number; // attached: reroute trigger time
  rerouteStartMs: number;
  rerouteEndMs: number;
  jitterMag: number; // 4-8px, fixed per filament
  widthFrac: number; // 0..1, fixed per filament
}

function buildFilaments(count: number, seed: number): Filament[] {
  const rand = mulberry32(seed);
  const filaments: Filament[] = [];
  for (let i = 0; i < count; i++) {
    const angle = (i / count) * Math.PI * 2 + (rand() - 0.5) * 0.3;
    filaments.push({
      phase: "attached",
      angle,
      oldAngle: angle,
      newAngle: angle,
      // staggered initial reroute so 11 filaments never all move together
      phaseEndMs: rand() * LIFETIME_MAX_MS,
      rerouteStartMs: 0,
      rerouteEndMs: 0,
      jitterMag: JITTER_MIN_PX + rand() * (JITTER_MAX_PX - JITTER_MIN_PX),
      widthFrac: rand(),
    });
  }
  return filaments;
}

/** walks one filament's phase machine forward to nowMs, catching up through
 * as many completed attached/rerouting cycles as have elapsed — a reroute
 * target is only ever chosen at the instant a filament's OWN cycle ends. */
function advanceFilament(
  f: Filament,
  nowMs: number,
  rand: () => number,
  pointerActive: boolean,
  pointerAngle: number,
) {
  for (let guard = 0; guard < 64; guard++) {
    if (f.phase === "attached") {
      if (nowMs < f.phaseEndMs) return;
      f.phase = "rerouting";
      f.oldAngle = f.angle;
      f.rerouteStartMs = f.phaseEndMs;
      f.rerouteEndMs = f.rerouteStartMs + REROUTE_MS;
      const biased = pointerActive && rand() < POINTER_BIAS_MAX_FRAC;
      f.newAngle = biased
        ? pointerAngle + (rand() - 0.5) * 2 * POINTER_BIAS_SPREAD_RAD
        : rand() * Math.PI * 2;
    } else {
      if (nowMs < f.rerouteEndMs) return;
      f.phase = "attached";
      f.angle = f.newAngle;
      const lifetime = LIFETIME_MIN_MS + rand() * (LIFETIME_MAX_MS - LIFETIME_MIN_MS);
      f.phaseEndMs = f.rerouteEndMs + lifetime;
    }
  }
}

function drawRing(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens) {
  ctx.save();
  ctx.globalAlpha = RING_ALPHA;
  ctx.strokeStyle = tokens.muted;
  ctx.lineWidth = 1;
  ctx.beginPath();
  ctx.arc(geo.cx, geo.cy, geo.ringRadius, 0, Math.PI * 2);
  ctx.stroke();
  ctx.restore();
}

function drawFilament(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens, f: Filament, idx: number, nowMs: number) {
  let angle: number;
  let length: number;
  let jittering: boolean;

  if (f.phase === "attached") {
    angle = f.angle;
    length = geo.ringRadius;
    jittering = true;
  } else {
    const p = Math.max(0, Math.min(1, (nowMs - f.rerouteStartMs) / REROUTE_MS));
    if (p < 0.5) {
      const local = p / 0.5;
      angle = f.oldAngle;
      length = geo.ringRadius * (1 - easeInOutCubic(local));
    } else {
      const local = (p - 0.5) / 0.5;
      angle = f.newAngle;
      length = geo.ringRadius * easeInOutCubic(local);
    }
    jittering = false;
  }

  const dirX = Math.cos(angle);
  const dirY = Math.sin(angle);
  const perpX = -dirY;
  const perpY = dirX;
  const bucket = Math.floor(nowMs / JITTER_BUCKET_MS);
  const jitterMag = f.jitterMag * geo.scale;

  const pts: { x: number; y: number }[] = [];
  for (let s = 0; s <= JITTER_SEGMENTS; s++) {
    const t = s / JITTER_SEGMENTS;
    const baseX = geo.cx + dirX * length * t;
    const baseY = geo.cy + dirY * length * t;
    if (s === 0 || s === JITTER_SEGMENTS || !jittering) {
      pts.push({ x: baseX, y: baseY });
      continue;
    }
    const taper = Math.sin(Math.PI * t); // pinned at center and tip, freest mid-span
    const j = hashJitter(idx, bucket, s) * jitterMag * taper;
    pts.push({ x: baseX + perpX * j, y: baseY + perpY * j });
  }

  const coreWidth = (CORE_WIDTH_MIN_PX + f.widthFrac * (CORE_WIDTH_MAX_PX - CORE_WIDTH_MIN_PX)) * geo.scale;

  ctx.save();
  ctx.lineJoin = "round";
  ctx.lineCap = "round";
  ctx.strokeStyle = tokens.fg;
  ctx.shadowColor = tokens.fg;
  ctx.shadowBlur = HALO_BLUR_PX * geo.scale;
  ctx.lineWidth = coreWidth;
  ctx.globalAlpha = 0.95;
  ctx.beginPath();
  const first = pts[0];
  if (!first) {
    ctx.restore();
    return;
  }
  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 PlasmaFilamentWanderProps {
  /** baseline filament count at full-bleed scale; thins automatically at card scale */
  filamentCount?: number;
  /** content rendered over the field (headline, CTA, etc.) */
  children?: ReactNode;
  /** extra classes merged onto the rendered root element */
  className?: string;
}

export function PlasmaFilamentWander({ filamentCount = BASELINE_COUNT, children, className = "" }: PlasmaFilamentWanderProps) {
  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 = 0x91a3f0;
    const rand = mulberry32(seed ^ 0x2545f4);

    let disposed = false;
    let tokens: Tokens | null = null;
    let dpr = 1;
    let geo: Geo = computeGeo(1, 1, filamentCount);
    let sized = false;
    let visible = true;
    let filaments: Filament[] = buildFilaments(geo.count, seed);

    let startMs = 0;
    let raf = 0;
    let tokenWaitRaf = 0;

    let pointerActive = false;
    let pointerAngle = 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;
      for (let i = 0; i < filaments.length; i++) {
        const f = filaments[i];
        if (f) advanceFilament(f, nowMs, rand, pointerActive, pointerAngle);
      }
      ctx.clearRect(0, 0, geo.W, geo.H);
      drawRing(ctx, geo, tokens);
      for (let i = 0; i < filaments.length; i++) {
        const f = filaments[i];
        if (f) drawFilament(ctx, geo, tokens, f, i, nowMs);
      }
    };

    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);
      const nextGeo = computeGeo(rect.width, rect.height, filamentCount);
      if (nextGeo.count !== geo.count) filaments = buildFilaments(nextGeo.count, seed);
      geo = nextGeo;
      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 = () => {
      render(STATIC_FREEZE_MS);
    };

    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 updatePointerFromClient = (clientX: number, clientY: number) => {
      const rect = root.getBoundingClientRect();
      const x = clientX - rect.left - geo.cx;
      const y = clientY - rect.top - geo.cy;
      const dist = Math.hypot(x, y);
      pointerAngle = Math.atan2(y, x);
      pointerActive = Math.abs(dist - geo.ringRadius) < POINTER_PROXIMITY_FRAC * geo.minDim;
    };

    const onPointerMove = (e: PointerEvent) => updatePointerFromClient(e.clientX, e.clientY);
    const onPointerLeave = () => {
      pointerActive = false;
    };

    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);

    if (!reduced) {
      root.addEventListener("pointermove", onPointerMove);
      root.addEventListener("pointerleave", onPointerLeave);
    }

    start();

    return () => {
      disposed = true;
      cancelAnimationFrame(raf);
      cancelAnimationFrame(tokenWaitRaf);
      ro.disconnect();
      mo.disconnect();
      io.disconnect();
      root.removeEventListener("pointermove", onPointerMove);
      root.removeEventListener("pointerleave", onPointerLeave);
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [filamentCount]);

  return (
    <section ref={rootRef} className={`relative isolate min-h-screen 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 min-h-screen w-full max-w-5xl flex-col items-start justify-center gap-4 px-6 py-24">
          {children}
        </div>
      ) : null}
    </section>
  );
}
Build spec

Build <PlasmaFilamentWander filamentCount? children? className?> as a full-bleed Canvas 2D hero background modelling a plasma globe. GEOMETRY: computeGeo(W, H, baselineCount) derives everything from the container's smaller dimension — ringRadius = minDim * 0.36 (the faint 'glass' reference circle, stroked once per frame in --ns-muted at 0.35 alpha, never a fill), a scale factor = clamp(minDim / 520, 0.55, 1.6) applied to filament width/jitter/halo so the same numbers read correctly from card scale up to a 100vw hero, and filamentCount = clamp(round(baselineCount * min(1, minDim/520)), 6, baselineCount) — the spec's 11-filament baseline holds at typical hero size and thins automatically toward 6 at card scale so the field never reads as noise (the kill criterion this exists to satisfy). STATE MACHINE: each filament is an independent object carrying phase ('attached' | 'rerouting'), its current angle, and a phaseEndMs/rerouteEndMs deadline; advanceFilament(f, nowMs, rand, pointerActive, pointerAngle) is called for every filament every frame and walks its phase machine forward through however many completed cycles fall between the last check and now (a for-loop with a 64-iteration guard, not a single if), which is what lets a background tab or the reduced-motion freeze jump straight to an arbitrary elapsed time without a filament ever appearing to teleport. TIMING: a filament attached at some angle holds it (with jitter, see below) until its own randomized phaseEndMs (1200-2600ms after its last reroute, drawn independently per filament — never a shared clock), staggered at init by giving each filament an initial phaseEndMs uniformly drawn from [0, 2600ms] so the 11 filaments never all move together; on reaching phaseEndMs it enters 'rerouting' for a fixed REROUTE_MS = 350ms, split into two eased halves via easeInOutCubic — the first half (0-175ms) shortens the filament from full ringRadius toward 0 at its OLD angle (retraction), the second half (175-350ms) regrows from 0 toward ringRadius at its NEW angle (re-extension) — so every reroute shows a real departure and a real arrival, never a blink or a snap. On completing the reroute the filament returns to 'attached' at the new angle and draws a fresh randomized 1200-2600ms lifetime. POINTER BIAS: pointerActive is true when the pointer's distance from the ring differs from ringRadius by less than 0.2 * minDim (tracked via a pointermove listener converting client coordinates into an angle+distance from the container's center); the bias is consulted ONLY inside advanceFilament, only at the exact instant a filament's own phaseEndMs is reached — with pointerActive true, each filament independently rolls a 40% chance (POINTER_BIAS_MAX_FRAC) to set its newAngle to pointerAngle plus a randomized +-0.35rad scatter instead of a uniform-random angle; the pointer never touches an already-attached or already-rerouting filament's current position, so it can only ever influence a filament that was already about to move on its own schedule — motion always originates from each filament's own clock, pointer proximity only ever redirects it. JITTER: while attached, a filament's interior polyline points (7 segments, JITTER_SEGMENTS, pinned exactly at the center and the ring so only the mid-span wanders, tapered by sin(pi*t)) are displaced perpendicular to the filament's direction by hashJitter(filamentIndex, floor(nowMs/100), segment) * (4-8px fixed per filament) * scale — a pure hash of the filament index, a 100ms time-bucket, and the segment index, so the ~10Hz plasma-noise resample falls out of flooring nowMs rather than needing a separate low-frequency timer or any persisted per-frame jitter state; jitter is zeroed during the 350ms reroute transition so the retract/regrow read stays clean. RENDER: each filament is stroked once (round joins/caps) in --foreground at 0.95 alpha with a canvas shadowBlur of 4px*scale in the same --foreground colour for its halo — luminance-only, --ns-accent never appears anywhere in the filament, halo or ring, per the standing accent-interaction-chrome-only rule; core width is 1.5-2.5px * scale, randomized per filament at build time and fixed for its life. Colour is read once via getComputedStyle(document.documentElement) for --foreground and --ns-muted with no 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; a ResizeObserver on the root recomputes geometry, resizes the canvas backing store (capped devicePixelRatio 2), and rebuilds the filament array from a fresh seed only when the computed count actually changes (a resize that doesn't cross a count threshold keeps every filament's in-flight phase intact); an IntersectionObserver stops driving new frames while off-screen and, on re-entering view, re-reads tokens and resumes the same continuous elapsed-time clock (filaments are never reset on re-entry, since each already self-corrects via advanceFilament's catch-up loop) — no paint happens on any of these three paths before a token read succeeds. Under prefers-reduced-motion the component runs zero rAF loops, zero timers, and attaches no pointer listeners at all: it renders exactly one deterministic frame by calling the same render/advanceFilament path once with nowMs = 1800 (a fixed seed's t=1.8s state — some filaments freshly regrown and short, most mid-jitter at full length, asymmetric and mid-cycle rather than the uniform t0 or fully-settled extremes) and never re-renders. Cleanup on unmount cancels both the animation and token-wait rAF handles, disconnects all three observers, and removes the pointer listeners. Optional children render over the field in a centered max-w-5xl column (headline/CTA slot, mirroring hero-ascii-lichtenberg's overlay pattern) with pointer-events left to the canvas's aria-hidden sibling so nothing intercepts clicks meant for real content. Zero dependencies, DOM+canvas only, no colour literal anywhere including fallbacks.

Props

PropTypeDefaultDescription
filamentCount?numberBASELINE_COUNTbaseline filament count at full-bleed scale; thins automatically at card scale
children?ReactNodecontent rendered over the field (headline, CTA, etc.)
className?stringextra classes merged onto the rendered root element