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.

Usage

registry/loud/plasma-filament-wander/demo.tsx
"use client";

import { PlasmaFilamentWander } from "./component";

export default function PlasmaFilamentWanderDemo() {
  return (
    <main className="min-h-screen bg-background">
      {/* hold the pointer near the ring — up to 40% of filaments bias their
          NEXT natural reroute toward it; the rest keep wandering on their own
          cadence, so nothing snaps and nothing reads as a spotlight */}
      <PlasmaFilamentWander>
        <span className="font-mono text-xs tracking-[0.25em] text-ns-muted">
          ns-ui / plasma-filament-wander
        </span>
        <h1 className="max-w-xl text-4xl font-semibold tracking-tight text-foreground sm:text-6xl">
          Every path reroutes on its own clock.
        </h1>
        <p className="max-w-md text-sm text-ns-muted sm:text-base">
          Eleven filaments reach for the ring, retract, and regrow toward a new
          point — each on its own staggered cadence, nudged but never dragged
          by the cursor.
        </p>
        <a
          href="#docs"
          className="mt-2 inline-flex items-center justify-center rounded-sm bg-ns-accent px-5 py-2.5 text-sm font-medium text-white transition-colors duration-150 hover:bg-ns-accent-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ns-accent"
        >
          Read the docs
        </a>
      </PlasmaFilamentWander>
    </main>
  );
}

Component

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

The usage example imports ./component because it sits next to the component file in this repo. Installed, that file lands at components/ui/plasma-filament-wander.tsx, so point the import there.

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.

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?ReactNode—content rendered over the field (headline, CTA, etc.)
className?string—extra classes merged onto the rendered root element