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.jsonSource
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
| Prop | Type | Default | Description |
|---|---|---|---|
| filamentCount? | number | BASELINE_COUNT | baseline 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 |