{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "plasma-filament-wander",
  "title": "Plasma Filament Wander",
  "description": "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.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/loud/plasma-filament-wander/component.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef } from \"react\";\nimport type { ReactNode } from \"react\";\n\n// ---------------------------------------------------------------------------\n// PlasmaGlobe — a full-bleed hero background modelled on a plasma globe: a\n// central electrode inside a low-pressure gas sphere ionizes discrete\n// filaments that reach from the center to the inner glass, constantly\n// wandering, retracting and reattaching elsewhere in a chaotic, ever-\n// rerouting pattern. Each filament runs its OWN independent lifecycle —\n// attached-and-jittering, or retracting-and-regrowing to a new target angle\n// — on its own randomized 1.2-2.6s cadence, staggered so the globe always\n// shows some motion while any single filament's own event stays trackable.\n//\n// A filament never teleports: advanceFilament() walks a filament's phase\n// machine forward through however many completed cycles fall between the\n// last frame and now (needed both for a background tab catching up and for\n// jumping straight to the reduced-motion freeze frame), and a reroute is\n// always the same 350ms eased retract-then-regrow, never a snap.\n//\n// Pointer proximity biases WHERE a filament reroutes to, never WHEN — the\n// bias is only consulted at a filament's own natural reroute moment, and\n// only ~40% of filaments take it, so the globe never reads as a spotlight\n// chasing the cursor. Filament jitter is resampled from a hashed function of\n// (filament, 100ms time-bucket) rather than persisted per-frame state, so a\n// ~10Hz \"plasma noise\" cadence falls out for free without an extra timer.\n// ---------------------------------------------------------------------------\n\nconst LIFETIME_MIN_MS = 1200;\nconst LIFETIME_MAX_MS = 2600;\nconst REROUTE_MS = 350; // eased retract-then-regrow transition\n\nconst JITTER_MIN_PX = 4;\nconst JITTER_MAX_PX = 8;\nconst JITTER_BUCKET_MS = 100; // ~10Hz resample, decoupled from the paint loop\nconst JITTER_SEGMENTS = 7; // interior points along a filament's polyline\n\nconst POINTER_PROXIMITY_FRAC = 0.2; // of the container's smaller dimension, measured from the ring\nconst POINTER_BIAS_MAX_FRAC = 0.4; // up to 40% of filaments take the bias at their own reroute\nconst POINTER_BIAS_SPREAD_RAD = 0.35; // randomized scatter around the pointer's angle\n\nconst CORE_WIDTH_MIN_PX = 1.5;\nconst CORE_WIDTH_MAX_PX = 2.5;\nconst HALO_BLUR_PX = 4;\n\nconst BASELINE_COUNT = 11;\nconst MIN_COUNT = 6;\nconst CARD_SCALE_REF = 520; // below this minDim, filament count thins to stay legible\n\nconst REF_DIM = 520; // width/jitter/radius scale reference\nconst SCALE_MIN = 0.55;\nconst SCALE_MAX = 1.6;\n\nconst RING_RADIUS_FRAC = 0.36; // of minDim\nconst RING_ALPHA = 0.35;\n\nconst STATIC_FREEZE_MS = 1800; // reduced-motion freeze: varied lengths, mid-cycle, asymmetric\n\ninterface Tokens {\n  fg: string;\n  muted: string;\n}\n\nfunction readTokens(): Tokens | null {\n  if (typeof document === \"undefined\") return null;\n  const cs = getComputedStyle(document.documentElement);\n  const fg = cs.getPropertyValue(\"--foreground\").trim();\n  const muted = cs.getPropertyValue(\"--ns-muted\").trim();\n  if (!fg || !muted) return null; // not loaded yet — no paint before this\n  return { fg, muted };\n}\n\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\n    t = Math.imul(t ^ (t >>> 15), t | 1);\n    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n  };\n}\n\n/** deterministic hashed pseudo-random in [-1, 1], keyed on filament + time-bucket + segment —\n * gives a discretely-resampled jitter with no persisted per-frame state. */\nfunction hashJitter(filamentIdx: number, bucket: number, segment: number): number {\n  let h = Math.imul(filamentIdx ^ 0x9e3779b9, 0x85ebca6b);\n  h ^= Math.imul((bucket + segment * 7349) ^ h, 0xc2b2ae35);\n  h ^= h >>> 16;\n  return ((h >>> 0) / 4294967296) * 2 - 1;\n}\n\nfunction easeInOutCubic(x: number): number {\n  const c = Math.max(0, Math.min(1, x));\n  return c < 0.5 ? 4 * c * c * c : 1 - Math.pow(-2 * c + 2, 3) / 2;\n}\n\ninterface Geo {\n  W: number;\n  H: number;\n  minDim: number;\n  cx: number;\n  cy: number;\n  ringRadius: number;\n  scale: number;\n  count: number;\n}\n\nfunction computeGeo(W: number, H: number, baselineCount: number): Geo {\n  const minDim = Math.min(W, H);\n  const scale = Math.max(SCALE_MIN, Math.min(SCALE_MAX, minDim / REF_DIM));\n  const count = Math.max(MIN_COUNT, Math.min(baselineCount, Math.round(baselineCount * Math.min(1, minDim / CARD_SCALE_REF))));\n  return {\n    W,\n    H,\n    minDim,\n    cx: W / 2,\n    cy: H / 2,\n    ringRadius: minDim * RING_RADIUS_FRAC,\n    scale,\n    count,\n  };\n}\n\ninterface Filament {\n  phase: \"attached\" | \"rerouting\";\n  angle: number; // current attached angle, or the retracting-from angle\n  oldAngle: number;\n  newAngle: number;\n  phaseEndMs: number; // attached: reroute trigger time\n  rerouteStartMs: number;\n  rerouteEndMs: number;\n  jitterMag: number; // 4-8px, fixed per filament\n  widthFrac: number; // 0..1, fixed per filament\n}\n\nfunction buildFilaments(count: number, seed: number): Filament[] {\n  const rand = mulberry32(seed);\n  const filaments: Filament[] = [];\n  for (let i = 0; i < count; i++) {\n    const angle = (i / count) * Math.PI * 2 + (rand() - 0.5) * 0.3;\n    filaments.push({\n      phase: \"attached\",\n      angle,\n      oldAngle: angle,\n      newAngle: angle,\n      // staggered initial reroute so 11 filaments never all move together\n      phaseEndMs: rand() * LIFETIME_MAX_MS,\n      rerouteStartMs: 0,\n      rerouteEndMs: 0,\n      jitterMag: JITTER_MIN_PX + rand() * (JITTER_MAX_PX - JITTER_MIN_PX),\n      widthFrac: rand(),\n    });\n  }\n  return filaments;\n}\n\n/** walks one filament's phase machine forward to nowMs, catching up through\n * as many completed attached/rerouting cycles as have elapsed — a reroute\n * target is only ever chosen at the instant a filament's OWN cycle ends. */\nfunction advanceFilament(\n  f: Filament,\n  nowMs: number,\n  rand: () => number,\n  pointerActive: boolean,\n  pointerAngle: number,\n) {\n  for (let guard = 0; guard < 64; guard++) {\n    if (f.phase === \"attached\") {\n      if (nowMs < f.phaseEndMs) return;\n      f.phase = \"rerouting\";\n      f.oldAngle = f.angle;\n      f.rerouteStartMs = f.phaseEndMs;\n      f.rerouteEndMs = f.rerouteStartMs + REROUTE_MS;\n      const biased = pointerActive && rand() < POINTER_BIAS_MAX_FRAC;\n      f.newAngle = biased\n        ? pointerAngle + (rand() - 0.5) * 2 * POINTER_BIAS_SPREAD_RAD\n        : rand() * Math.PI * 2;\n    } else {\n      if (nowMs < f.rerouteEndMs) return;\n      f.phase = \"attached\";\n      f.angle = f.newAngle;\n      const lifetime = LIFETIME_MIN_MS + rand() * (LIFETIME_MAX_MS - LIFETIME_MIN_MS);\n      f.phaseEndMs = f.rerouteEndMs + lifetime;\n    }\n  }\n}\n\nfunction drawRing(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens) {\n  ctx.save();\n  ctx.globalAlpha = RING_ALPHA;\n  ctx.strokeStyle = tokens.muted;\n  ctx.lineWidth = 1;\n  ctx.beginPath();\n  ctx.arc(geo.cx, geo.cy, geo.ringRadius, 0, Math.PI * 2);\n  ctx.stroke();\n  ctx.restore();\n}\n\nfunction drawFilament(ctx: CanvasRenderingContext2D, geo: Geo, tokens: Tokens, f: Filament, idx: number, nowMs: number) {\n  let angle: number;\n  let length: number;\n  let jittering: boolean;\n\n  if (f.phase === \"attached\") {\n    angle = f.angle;\n    length = geo.ringRadius;\n    jittering = true;\n  } else {\n    const p = Math.max(0, Math.min(1, (nowMs - f.rerouteStartMs) / REROUTE_MS));\n    if (p < 0.5) {\n      const local = p / 0.5;\n      angle = f.oldAngle;\n      length = geo.ringRadius * (1 - easeInOutCubic(local));\n    } else {\n      const local = (p - 0.5) / 0.5;\n      angle = f.newAngle;\n      length = geo.ringRadius * easeInOutCubic(local);\n    }\n    jittering = false;\n  }\n\n  const dirX = Math.cos(angle);\n  const dirY = Math.sin(angle);\n  const perpX = -dirY;\n  const perpY = dirX;\n  const bucket = Math.floor(nowMs / JITTER_BUCKET_MS);\n  const jitterMag = f.jitterMag * geo.scale;\n\n  const pts: { x: number; y: number }[] = [];\n  for (let s = 0; s <= JITTER_SEGMENTS; s++) {\n    const t = s / JITTER_SEGMENTS;\n    const baseX = geo.cx + dirX * length * t;\n    const baseY = geo.cy + dirY * length * t;\n    if (s === 0 || s === JITTER_SEGMENTS || !jittering) {\n      pts.push({ x: baseX, y: baseY });\n      continue;\n    }\n    const taper = Math.sin(Math.PI * t); // pinned at center and tip, freest mid-span\n    const j = hashJitter(idx, bucket, s) * jitterMag * taper;\n    pts.push({ x: baseX + perpX * j, y: baseY + perpY * j });\n  }\n\n  const coreWidth = (CORE_WIDTH_MIN_PX + f.widthFrac * (CORE_WIDTH_MAX_PX - CORE_WIDTH_MIN_PX)) * geo.scale;\n\n  ctx.save();\n  ctx.lineJoin = \"round\";\n  ctx.lineCap = \"round\";\n  ctx.strokeStyle = tokens.fg;\n  ctx.shadowColor = tokens.fg;\n  ctx.shadowBlur = HALO_BLUR_PX * geo.scale;\n  ctx.lineWidth = coreWidth;\n  ctx.globalAlpha = 0.95;\n  ctx.beginPath();\n  const first = pts[0];\n  if (!first) {\n    ctx.restore();\n    return;\n  }\n  ctx.moveTo(first.x, first.y);\n  for (let i = 1; i < pts.length; i++) {\n    const p = pts[i];\n    if (p) ctx.lineTo(p.x, p.y);\n  }\n  ctx.stroke();\n  ctx.restore();\n}\n\nexport interface PlasmaFilamentWanderProps {\n  /** baseline filament count at full-bleed scale; thins automatically at card scale */\n  filamentCount?: number;\n  /** content rendered over the field (headline, CTA, etc.) */\n  children?: ReactNode;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nexport function PlasmaFilamentWander({ filamentCount = BASELINE_COUNT, children, className = \"\" }: PlasmaFilamentWanderProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    if (!root || !canvas) return;\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n\n    const reduced = window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const seed = 0x91a3f0;\n    const rand = mulberry32(seed ^ 0x2545f4);\n\n    let disposed = false;\n    let tokens: Tokens | null = null;\n    let dpr = 1;\n    let geo: Geo = computeGeo(1, 1, filamentCount);\n    let sized = false;\n    let visible = true;\n    let filaments: Filament[] = buildFilaments(geo.count, seed);\n\n    let startMs = 0;\n    let raf = 0;\n    let tokenWaitRaf = 0;\n\n    let pointerActive = false;\n    let pointerAngle = 0;\n\n    const fitCanvas = () => {\n      canvas.width = Math.max(1, Math.round(geo.W * dpr));\n      canvas.height = Math.max(1, Math.round(geo.H * dpr));\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n    };\n\n    const render = (nowMs: number) => {\n      if (!tokens || !sized) return;\n      for (let i = 0; i < filaments.length; i++) {\n        const f = filaments[i];\n        if (f) advanceFilament(f, nowMs, rand, pointerActive, pointerAngle);\n      }\n      ctx.clearRect(0, 0, geo.W, geo.H);\n      drawRing(ctx, geo, tokens);\n      for (let i = 0; i < filaments.length; i++) {\n        const f = filaments[i];\n        if (f) drawFilament(ctx, geo, tokens, f, i, nowMs);\n      }\n    };\n\n    const resizeAll = () => {\n      if (!tokens) return;\n      const rect = root.getBoundingClientRect();\n      if (rect.width < 4 || rect.height < 4) {\n        sized = false;\n        return;\n      }\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const nextGeo = computeGeo(rect.width, rect.height, filamentCount);\n      if (nextGeo.count !== geo.count) filaments = buildFilaments(nextGeo.count, seed);\n      geo = nextGeo;\n      fitCanvas();\n      sized = true;\n    };\n\n    const loop = (nowRaf: number) => {\n      if (disposed) return;\n      if (!visible) {\n        raf = 0; // IntersectionObserver re-arms this on re-entering view\n        return;\n      }\n      raf = requestAnimationFrame(loop);\n      if (!sized || !tokens) return;\n      if (startMs === 0) startMs = nowRaf;\n      render(nowRaf - startMs);\n    };\n\n    const buildReducedFrame = () => {\n      render(STATIC_FREEZE_MS);\n    };\n\n    let started = false;\n    const kick = () => {\n      if (started || disposed || !tokens || !sized) return;\n      started = true;\n      if (reduced) {\n        buildReducedFrame();\n        return; // no rAF loop, no timers, no observers driving motion\n      }\n      raf = requestAnimationFrame(loop);\n    };\n\n    const start = () => {\n      if (disposed) return;\n      tokens = readTokens();\n      if (!tokens) {\n        tokenWaitRaf = requestAnimationFrame(start);\n        return;\n      }\n      resizeAll();\n      kick();\n    };\n\n    const updatePointerFromClient = (clientX: number, clientY: number) => {\n      const rect = root.getBoundingClientRect();\n      const x = clientX - rect.left - geo.cx;\n      const y = clientY - rect.top - geo.cy;\n      const dist = Math.hypot(x, y);\n      pointerAngle = Math.atan2(y, x);\n      pointerActive = Math.abs(dist - geo.ringRadius) < POINTER_PROXIMITY_FRAC * geo.minDim;\n    };\n\n    const onPointerMove = (e: PointerEvent) => updatePointerFromClient(e.clientX, e.clientY);\n    const onPointerLeave = () => {\n      pointerActive = false;\n    };\n\n    const ro = new ResizeObserver(() => {\n      if (!tokens) return;\n      resizeAll();\n      if (reduced) buildReducedFrame();\n      kick();\n    });\n    ro.observe(root);\n\n    const mo = new MutationObserver(() => {\n      tokens = readTokens();\n      if (tokens) {\n        resizeAll();\n        if (reduced) buildReducedFrame();\n        kick();\n      }\n    });\n    mo.observe(document.documentElement, { attributes: true, attributeFilter: [\"class\"] });\n\n    const io = new IntersectionObserver((entries) => {\n      const wasVisible = visible;\n      visible = entries[0]?.isIntersecting ?? true;\n      if (visible && !wasVisible && !reduced && tokens && !raf) {\n        tokens = readTokens() ?? tokens; // pick up any theme flip that happened while hidden\n        resizeAll();\n        raf = requestAnimationFrame(loop);\n      }\n    });\n    io.observe(root);\n\n    if (!reduced) {\n      root.addEventListener(\"pointermove\", onPointerMove);\n      root.addEventListener(\"pointerleave\", onPointerLeave);\n    }\n\n    start();\n\n    return () => {\n      disposed = true;\n      cancelAnimationFrame(raf);\n      cancelAnimationFrame(tokenWaitRaf);\n      ro.disconnect();\n      mo.disconnect();\n      io.disconnect();\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n    // eslint-disable-next-line react-hooks/exhaustive-deps\n  }, [filamentCount]);\n\n  return (\n    <section ref={rootRef} className={`relative isolate min-h-screen w-full overflow-hidden bg-background ${className}`}>\n      <canvas ref={canvasRef} aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0 h-full w-full\" />\n      {children ? (\n        <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\">\n          {children}\n        </div>\n      ) : null}\n    </section>\n  );\n}\n",
      "type": "registry:ui",
      "target": "components/ui/plasma-filament-wander.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)"
    },
    "light": {
      "ns-muted": "#4d4d4d"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "loud",
    "tags": [
      "hero",
      "canvas",
      "plasma",
      "cursor",
      "filament",
      "ambient",
      "wander",
      "background"
    ],
    "instruction": "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."
  },
  "type": "registry:ui"
}