Skip to main content

ns-ui / loud

Hero Vortex Street

Cursor fluid: moving the pointer through a full-bleed dark field sheds a von Karman vortex street of alternating-sign vortices that drift downstream and decay, revealed by ~600 velocity-aligned tracer streaks; clicking drops a counter-rotating vortex pair that stirs the field for exactly 3 seconds.

Usage

registry/loud/hero-vortex-street/demo.tsx
"use client";

import { VortexStreet } from "./component";

// Full-viewport field. The component is self-driving: with no pointer
// activity its internal Lissajous driver keeps wandering the canvas and
// shedding the vortex street on its own, so the demo animates without any
// input. Labels are pointer-events-none so they never block the canvas's
// pointer/click handling.
export default function VortexStreetDemo() {
  return (
    <div className="relative h-screen w-full overflow-hidden bg-background">
      <div className="absolute inset-0">
        <VortexStreet />
      </div>
      <div className="pointer-events-none absolute inset-x-0 top-8 flex flex-col items-center gap-2 text-center">
        <p className="font-mono text-xs uppercase tracking-[0.2em] text-ns-muted">
          ns-ui / hero-vortex-street
        </p>
        <p className="text-xs text-ns-muted">
          Move the cursor to shed vortices; click to stir the field for 3s.
        </p>
      </div>
    </div>
  );
}

Component

registry/loud/hero-vortex-street/component.tsx
"use client";

import { useEffect, useRef } from "react";

// ---------------------------------------------------------------------------
// VortexStreet — cursor fluid. Moving the pointer through a full-bleed dark
// field sheds a von Karman vortex street: alternating-sign vortex blobs spawn
// behind the cursor at speed-dependent intervals, drift downstream on a
// gentle ambient current, and decay away over a few seconds. ~600 tracer
// particles (Float32Array positions, zero per-frame allocation) are advected
// every frame by the summed Lamb-Oseen velocity field of all active vortices
// plus the ambient drift, and drawn as velocity-aligned streaks whose alpha
// scales with speed — fast tracers get an extra blue-white glow pass.
//
// The shedding "driver" is the real pointer while it has moved within the
// last 1.5s, and an internal slow Lissajous orbit otherwise — one mechanism
// gives both idle ambient motion (the field is never static) and a
// self-driving demo with no synthetic events. A real pointerdown drops a
// standing counter-rotating vortex pair at the click point with elevated
// strength that decays to nothing over exactly 3 seconds — a 3s stir.
//
// All ink is read from CSS custom properties at mount and re-read via a
// MutationObserver on documentElement's class attribute, so both themes
// render correctly. The rAF loop pauses on visibilitychange; the backing
// store is dpr-clamped(2) with a zero-size guard. No interactive controls —
// the canvas is aria-hidden decoration, matching hero-particles-webgl's precedent.
//
// prefers-reduced-motion: no rAF loop, no pointer tracking at all. One
// static frame is drawn instead: streamlines integrated once through a fixed
// four-vortex street configuration and stroked as thin curves.
// ---------------------------------------------------------------------------

export interface VortexStreetProps {
  /** extra classes merged onto the rendered root element */
  className?: string;
}

const TRACER_COUNT = 600;
const MAX_VORTICES = 24;
const CORE_R = 34; // Lamb-Oseen core radius, px
const AMBIENT_X = 22; // ambient drift, px/s — the field is never static
const AMBIENT_Y = -7;
const SHED_MIN_SPEED = 90; // px/s driver speed before shedding starts
const SHED_SPACING = 44; // px of driver travel per shed vortex
const SHED_GAMMA_PER_SPEED = 140; // circulation = 140 * clamped speed
const SHED_SPEED_LO = 120;
const SHED_SPEED_HI = 800;
const SHED_TAU = 2.2; // s, exponential strength decay of shed vortices
const GAMMA_PRUNE = 1500; // |Γ| below this = negligible, slot freed
const CLICK_GAMMA = 90000; // elevated strength for the click pair
const CLICK_LIFE = 3; // s, exact lifetime of a click vortex
const CLICK_PAIR_OFFSET = 16; // px, half-separation of the pair
const POINTER_HOLD = 1.5; // s of pointer recency before idle orbit resumes
const DRIVER_EASE = 6; // 1/s exponential smoothing toward the target
const STREAK_K = 0.055; // s — streak length = |v| * K (≈3–5px at drift speed)
const GLOW_SPEED = 150; // px/s, above this a tracer gets the glow pass
const TWO_PI = Math.PI * 2;

function parseHex(raw: string): [number, number, number] | null {
  const m = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(raw.trim());
  if (!m) return null;
  let h = m[1];
  if (h.length === 3) h = h.split("").map((c) => c + c).join("");
  const n = parseInt(h, 16);
  return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
}

function mixToWhite(c: [number, number, number], t: number): [number, number, number] {
  return [
    Math.round(c[0] + (255 - c[0]) * t),
    Math.round(c[1] + (255 - c[1]) * t),
    Math.round(c[2] + (255 - c[2]) * t),
  ];
}

export function VortexStreet({ className = "" }: VortexStreetProps) {
  const containerRef = useRef<HTMLDivElement | null>(null);
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const container = containerRef.current;
    const canvas = canvasRef.current;
    if (!container || !canvas) return;
    const ctx = canvas.getContext("2d");
    if (!ctx) return;

    let w = 0;
    let h = 0;
    let raf = 0;
    let running = false;
    let last = 0;
    let seeded = false;

    // ---- ink, read from tokens; re-read on theme flip ---------------------
    let fgStyle = "#888";
    let mutedStyle = "#888";
    let glowStyle = "rgb(128,180,255)";
    let fadeStyle = "rgba(10,10,10,0.32)";
    let bgStyle = "#0a0a0a";
    const readInk = () => {
      const cs = getComputedStyle(document.documentElement);
      const fg = cs.getPropertyValue("--foreground").trim();
      const muted = cs.getPropertyValue("--ns-muted").trim();
      const bg = cs.getPropertyValue("--background").trim();
      const accent = cs.getPropertyValue("--ns-accent").trim();
      fgStyle = fg || "#888";
      mutedStyle = muted || "#888";
      bgStyle = bg || "#0a0a0a";
      const bgRgb = parseHex(bg) ?? [10, 10, 10];
      fadeStyle = `rgba(${bgRgb[0]},${bgRgb[1]},${bgRgb[2]},0.32)`;
      // blue-white glow: the accent token pulled halfway toward white
      const g = mixToWhite(parseHex(accent) ?? [0, 107, 255], 0.5);
      glowStyle = `rgb(${g[0]},${g[1]},${g[2]})`;
    };
    readInk();

    // ---- tracers: typed arrays, mutated in place --------------------------
    const tx = new Float32Array(TRACER_COUNT);
    const ty = new Float32Array(TRACER_COUNT);

    // ---- vortex store: fixed capacity, slots reused -----------------------
    const vActive = new Uint8Array(MAX_VORTICES);
    const vx = new Float32Array(MAX_VORTICES);
    const vy = new Float32Array(MAX_VORTICES);
    const vg = new Float32Array(MAX_VORTICES); // signed initial circulation
    const vBirth = new Float32Array(MAX_VORTICES); // s
    const vKind = new Uint8Array(MAX_VORTICES); // 0 = shed, 1 = click
    const vEff = new Float32Array(MAX_VORTICES); // per-frame effective Γ
    const activeIdx = new Int32Array(MAX_VORTICES);

    const allocSlot = (): number => {
      let oldest = 0;
      let oldestBirth = Infinity;
      for (let i = 0; i < MAX_VORTICES; i++) {
        if (!vActive[i]) return i;
        if (vBirth[i] < oldestBirth) {
          oldestBirth = vBirth[i];
          oldest = i;
        }
      }
      return oldest; // full: evict the oldest
    };

    const spawnVortex = (x: number, y: number, gamma: number, kind: 0 | 1, nowS: number) => {
      const i = allocSlot();
      vActive[i] = 1;
      vx[i] = x;
      vy[i] = y;
      vg[i] = gamma;
      vBirth[i] = nowS;
      vKind[i] = kind;
    };

    // ---- driver: real pointer when fresh, Lissajous orbit otherwise -------
    let pointerX = 0;
    let pointerY = 0;
    let lastPointerT = -Infinity; // s
    let driverX = 0;
    let driverY = 0;
    let driverInit = false;
    let shedSign = 1;
    let travel = 0;

    const lissajousX = (t: number) => w * (0.5 + 0.36 * Math.sin(0.5 * t + 1.3));
    const lissajousY = (t: number) => h * (0.5 + 0.34 * Math.sin(0.34 * t));

    // ---- per-frame step ---------------------------------------------------
    const step = (nowS: number, dt: number) => {
      // decay + prune vortices, compact active indices
      let nActive = 0;
      for (let i = 0; i < MAX_VORTICES; i++) {
        if (!vActive[i]) continue;
        const age = nowS - vBirth[i];
        let eff: number;
        if (vKind[i] === 1) {
          // click vortex: gone at exactly CLICK_LIFE, quadratic ease-out
          if (age >= CLICK_LIFE) {
            vActive[i] = 0;
            continue;
          }
          const k = 1 - age / CLICK_LIFE;
          eff = vg[i] * k * k;
        } else {
          eff = vg[i] * Math.exp(-age / SHED_TAU);
        }
        if (eff > -GAMMA_PRUNE && eff < GAMMA_PRUNE) {
          vActive[i] = 0;
          continue;
        }
        vEff[i] = eff;
        activeIdx[nActive++] = i;
      }

      // drive: ease toward pointer (if fresh) or the idle orbit
      const usePointer = nowS - lastPointerT < POINTER_HOLD;
      const targetX = usePointer ? pointerX : lissajousX(nowS);
      const targetY = usePointer ? pointerY : lissajousY(nowS);
      if (!driverInit) {
        driverX = targetX;
        driverY = targetY;
        driverInit = true;
      }
      const ease = 1 - Math.exp(-DRIVER_EASE * dt);
      const px = driverX;
      const py = driverY;
      driverX += (targetX - driverX) * ease;
      driverY += (targetY - driverY) * ease;
      const dvx = driverX - px;
      const dvy = driverY - py;
      const dist = Math.sqrt(dvx * dvx + dvy * dvy);
      const speed = dt > 0 ? dist / dt : 0;

      // shed: accumulate travel above the speed threshold, alternate sign
      if (speed > SHED_MIN_SPEED) {
        travel += dist;
        if (travel >= SHED_SPACING) {
          travel -= SHED_SPACING;
          const inv = dist > 0 ? 1 / dist : 0;
          const ux = dvx * inv;
          const uy = dvy * inv;
          const s = speed < SHED_SPEED_LO ? SHED_SPEED_LO : speed > SHED_SPEED_HI ? SHED_SPEED_HI : speed;
          // behind the driver, offset to alternating sides — the two rows
          // of the street
          spawnVortex(
            driverX - ux * 12 - uy * 10 * shedSign,
            driverY - uy * 12 + ux * 10 * shedSign,
            shedSign * SHED_GAMMA_PER_SPEED * s,
            0,
            nowS
          );
          shedSign = -shedSign;
        }
      } else {
        travel = 0;
      }

      // paint: translucent background fill leaves short-lived trails
      ctx.globalAlpha = 1;
      ctx.fillStyle = fadeStyle;
      ctx.fillRect(0, 0, w, h);

      // faint rotating glyph rings hint at the invisible vortex centers
      ctx.strokeStyle = mutedStyle;
      ctx.lineWidth = 1;
      for (let a = 0; a < nActive; a++) {
        const i = activeIdx[a];
        const eff = vEff[i];
        const mag = eff < 0 ? -eff : eff;
        const alpha = 0.1 * (mag > 40000 ? 1 : mag / 40000);
        if (alpha < 0.008) continue;
        const r = CORE_R * 0.85;
        ctx.globalAlpha = alpha;
        ctx.beginPath();
        ctx.arc(vx[i], vy[i], r, 0, TWO_PI);
        ctx.stroke();
        const ang = (eff > 0 ? 1 : -1) * nowS * 1.4 + i * 1.7;
        const ca = Math.cos(ang);
        const sa = Math.sin(ang);
        ctx.beginPath();
        ctx.moveTo(vx[i] + ca * r, vy[i] + sa * r);
        ctx.lineTo(vx[i] + ca * (r + 5), vy[i] + sa * (r + 5));
        ctx.stroke();
      }

      // advect + draw tracers as velocity-aligned streaks
      const coreR2 = CORE_R * CORE_R;
      ctx.lineWidth = 1.1;
      for (let p = 0; p < TRACER_COUNT; p++) {
        const x = tx[p];
        const y = ty[p];
        let velX = AMBIENT_X;
        let velY = AMBIENT_Y;
        for (let a = 0; a < nActive; a++) {
          const i = activeIdx[a];
          const dx = x - vx[i];
          const dy = y - vy[i];
          let r2 = dx * dx + dy * dy;
          if (r2 < 1e-6) r2 = 1e-6;
          // Lamb-Oseen: v_theta = (Γ / 2πr)(1 − e^{−(r/rc)²}), tangential —
          // as a multiplier on (−dy, dx) that's Γ(1 − e)/(2πr²)
          const f = (vEff[i] * (1 - Math.exp(-r2 / coreR2))) / (TWO_PI * r2);
          velX += -dy * f;
          velY += dx * f;
        }
        let nx = x + velX * dt;
        let ny = y + velY * dt;
        // wrap with a small margin
        if (nx < -6) nx = w + 6;
        else if (nx > w + 6) nx = -6;
        if (ny < -6) ny = h + 6;
        else if (ny > h + 6) ny = -6;
        tx[p] = nx;
        ty[p] = ny;

        const sp = Math.sqrt(velX * velX + velY * velY);
        const alpha = sp * 0.003 + 0.08;
        const x0 = nx - velX * STREAK_K;
        const y0 = ny - velY * STREAK_K;
        if (sp > GLOW_SPEED) {
          // blue-white glow pass under fast streaks
          ctx.strokeStyle = glowStyle;
          ctx.lineWidth = 3;
          ctx.globalAlpha = Math.min(0.4, (sp - GLOW_SPEED) * 0.0016 + 0.12);
          ctx.beginPath();
          ctx.moveTo(x0, y0);
          ctx.lineTo(nx, ny);
          ctx.stroke();
          ctx.lineWidth = 1.1;
        }
        ctx.strokeStyle = fgStyle;
        ctx.globalAlpha = alpha > 0.85 ? 0.85 : alpha;
        ctx.beginPath();
        ctx.moveTo(x0, y0);
        ctx.lineTo(nx, ny);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
    };

    const loop = (t: number) => {
      const dt = Math.min((t - (last || t)) / 1000, 1 / 30);
      last = t;
      step(t / 1000, dt);
      raf = requestAnimationFrame(loop);
    };
    const wake = () => {
      if (!running) {
        running = true;
        last = 0;
        raf = requestAnimationFrame(loop);
      }
    };
    const sleep = () => {
      cancelAnimationFrame(raf);
      running = false;
    };

    // ---- reduced motion: one static frame of integrated streamlines -------
    const drawStatic = () => {
      ctx.fillStyle = bgStyle;
      ctx.fillRect(0, 0, w, h);
      // fixed vortex street: four alternating vortices along the midline
      const sx = [0.3, 0.44, 0.58, 0.72];
      const sg = [52000, -52000, 52000, -52000];
      const coreR2 = CORE_R * CORE_R;
      const vel = (x: number, y: number): [number, number] => {
        let ax = AMBIENT_X * 2.4;
        let ay = 0;
        for (let i = 0; i < 4; i++) {
          const cx0 = sx[i] * w;
          const cy0 = h * (i % 2 === 0 ? 0.44 : 0.56);
          const dx = x - cx0;
          const dy = y - cy0;
          let r2 = dx * dx + dy * dy;
          if (r2 < 1e-6) r2 = 1e-6;
          const f = (sg[i] * (1 - Math.exp(-r2 / coreR2))) / (TWO_PI * r2);
          ax += -dy * f;
          ay += dx * f;
        }
        return [ax, ay];
      };
      // integrate seed points through the frozen field, stroke as curves
      ctx.strokeStyle = fgStyle;
      ctx.lineWidth = 1;
      ctx.globalAlpha = 0.32;
      const seeds = 26;
      for (let s = 0; s < seeds; s++) {
        let x = -4;
        let y = (h * (s + 0.5)) / seeds;
        ctx.beginPath();
        ctx.moveTo(x, y);
        for (let k = 0; k < 300; k++) {
          const [ux, uy] = vel(x, y);
          x += ux * 0.02;
          y += uy * 0.02;
          if (x < -8 || x > w + 8 || y < -8 || y > h + 8) break;
          ctx.lineTo(x, y);
        }
        ctx.stroke();
      }
      // ring hints at the vortex centers
      ctx.strokeStyle = mutedStyle;
      ctx.globalAlpha = 0.3;
      for (let i = 0; i < 4; i++) {
        ctx.beginPath();
        ctx.arc(sx[i] * w, h * (i % 2 === 0 ? 0.44 : 0.56), CORE_R * 0.85, 0, TWO_PI);
        ctx.stroke();
      }
      ctx.globalAlpha = 1;
    };

    // ---- pointer (attached only when motion is allowed) -------------------
    const onPointerMove = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      pointerX = e.clientX - rect.left;
      pointerY = e.clientY - rect.top;
      lastPointerT = performance.now() / 1000;
    };
    const onPointerDown = (e: PointerEvent) => {
      const rect = container.getBoundingClientRect();
      const cx = e.clientX - rect.left;
      const cy = e.clientY - rect.top;
      const nowS = performance.now() / 1000;
      // standing counter-rotating pair — stirs the field for 3s
      spawnVortex(cx - CLICK_PAIR_OFFSET, cy, CLICK_GAMMA, 1, nowS);
      spawnVortex(cx + CLICK_PAIR_OFFSET, cy, -CLICK_GAMMA, 1, nowS);
      pointerX = cx;
      pointerY = cy;
      lastPointerT = nowS;
    };

    // ---- mode plumbing ----------------------------------------------------
    const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
    let reduced = mq.matches;
    let listening = false;
    const applyMode = () => {
      if (reduced) {
        sleep();
        if (listening) {
          container.removeEventListener("pointermove", onPointerMove);
          container.removeEventListener("pointerdown", onPointerDown);
          listening = false;
        }
        if (w > 0 && h > 0) drawStatic();
      } else {
        if (!listening) {
          container.addEventListener("pointermove", onPointerMove);
          container.addEventListener("pointerdown", onPointerDown);
          listening = true;
        }
        if (w > 0 && h > 0) wake();
      }
    };
    const onMq = () => {
      reduced = mq.matches;
      applyMode();
    };
    mq.addEventListener("change", onMq);

    const resize = () => {
      const rect = container.getBoundingClientRect();
      if (rect.width < 4 || rect.height < 4) return; // zero-size guard
      w = rect.width;
      h = rect.height;
      const dpr = Math.min(window.devicePixelRatio || 1, 2);
      canvas.width = Math.round(w * dpr);
      canvas.height = Math.round(h * dpr);
      canvas.style.width = `${w}px`;
      canvas.style.height = `${h}px`;
      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
      if (!seeded) {
        for (let p = 0; p < TRACER_COUNT; p++) {
          tx[p] = Math.random() * w;
          ty[p] = Math.random() * h;
        }
        seeded = true;
      }
      // resizing clears the canvas — repaint an opaque base so trails don't
      // start from transparency
      ctx.fillStyle = bgStyle;
      ctx.fillRect(0, 0, w, h);
      applyMode();
    };
    const ro = new ResizeObserver(resize);
    ro.observe(container);
    resize();

    const themeObserver = new MutationObserver(() => {
      readInk();
      if (w <= 0 || h <= 0) return;
      // flush old-theme trail pixels immediately
      ctx.fillStyle = bgStyle;
      ctx.fillRect(0, 0, w, h);
      if (reduced) drawStatic();
    });
    themeObserver.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });

    const onVis = () => {
      if (document.hidden) sleep();
      else if (!reduced) wake();
    };
    document.addEventListener("visibilitychange", onVis);

    return () => {
      ro.disconnect();
      themeObserver.disconnect();
      mq.removeEventListener("change", onMq);
      document.removeEventListener("visibilitychange", onVis);
      if (listening) {
        container.removeEventListener("pointermove", onPointerMove);
        container.removeEventListener("pointerdown", onPointerDown);
      }
      sleep();
    };
  }, []);

  return (
    <div
      ref={containerRef}
      className={`relative h-full w-full overflow-hidden bg-background ${className}`}
    >
      <canvas
        ref={canvasRef}
        aria-hidden="true"
        className="pointer-events-none absolute inset-0"
      />
    </div>
  );
}

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

Use when a full-bleed decorative fluid backdrop with no text and no controls of its own, where the cursor sheds a vortex street that drifts and decays behind whatever the caller overlays; pick hero-dipole-field or hero-gravity-well instead when the headline itself is the subject of the effect, or hero-particles-webgl when true GPU depth is worth three npm dependencies.

Install

npx shadcn add https://design.helpmarq.com/r/hero-vortex-street.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 a full-bleed decorative Canvas 2D fluid field with zero interactive controls (canvas aria-hidden, pointer-events-none on the canvas itself with pointer handlers on its container — the direct analogue of a cursor-reactive particle hero). Backing store is dpr-clamped to 2 with a ResizeObserver-driven resize and a zero-size guard (skip when the container is under 4px). SIMULATION: 600 tracer particles live in two Float32Arrays (x and y) mutated in place — no allocation, .map, .filter, or object creation anywhere in the per-frame path. Each frame every tracer is advected by dt (clamped to 1/30 s) under the SUM of (a) a constant gentle ambient drift of (22, −7) px/s so the field is never static even with zero vortices, and (b) the tangential Lamb-Oseen velocity of every active vortex: v_theta = (Γ / 2πr)(1 − exp(−(r/r_core)²)) with r_core = 34 px, applied as the multiplier Γ(1 − exp(−r²/r_core²))/(2πr²) on the perpendicular vector (−dy, dx) from vortex center to tracer (regular at r→0, guard r² with 1e-6). Tracers wrap at the edges with a 6 px margin. VORTEX STORE: fixed capacity 24 in parallel typed arrays (x, y, signed initial Γ, birth time, kind flag, active flag, plus a per-frame effective-Γ scratch array and a compacted active-index Int32Array); slot allocation reuses any inactive slot else evicts the oldest. Shed vortices decay as Γ·exp(−age/2.2 s) and are pruned when |Γ_eff| < 1500; click vortices decay as Γ·(1 − age/3)² and are removed at exactly age = 3 s. SHEDDING DRIVER: a driver position eases exponentially (rate 6 s⁻¹, factor 1 − exp(−6·dt)) toward the real pointer position if the pointer moved within the last 1.5 s, otherwise toward an internal slow Lissajous orbit x = w(0.5 + 0.36·sin(0.5t + 1.3)), y = h(0.5 + 0.34·sin(0.34t)) — this single mechanism gives idle ambient shedding (self-driving demo, no synthetic events) and seamless pointer takeover. When driver speed exceeds 90 px/s, accumulate distance traveled; every 44 px spawn one vortex with ALTERNATING sign, Γ = ±140 × speed (speed clamped to 120–800 px/s), positioned 12 px behind the driver along −velocity plus 10 px perpendicular offset to the alternating side, producing the characteristic two-row zig-zag street. CLICK: a real pointerdown spawns a standing counter-rotating pair at the click point, ±16 px apart horizontally, Γ = ±90000 (elevated), gone after exactly 3 s. RENDER: each frame first fill the whole canvas with the --background token at alpha 0.32 (short-lived motion trails), then draw faint rotating glyph rings on each active vortex (a 1px --ns-muted circle of radius 0.85·r_core plus a 5 px radial tick rotating at 1.4 rad/s signed by rotation direction, alpha 0.1 scaled by |Γ_eff|/40000, capped), then draw every tracer as a streak from (x − vx·k, y − vy·k) to (x, y) with k = 0.055 s in --foreground, alpha = 0.08 + speed·0.003 capped at 0.85; tracers faster than 150 px/s first get a glow under-stroke 3 px wide in a blue-white derived by mixing the --ns-accent token 50% toward white, alpha 0.12 + (speed − 150)·0.0016 capped at 0.4. ALL canvas ink comes from CSS custom properties read via getComputedStyle(document.documentElement) at mount and re-read through a MutationObserver on documentElement's class attribute (repaint an opaque background flush on theme flip so old-theme trail pixels vanish). The rAF loop pauses on visibilitychange. REDUCED MOTION (matchMedia, tracked live): no rAF loop and no pointer listeners at all — render ONE static frame: 26 seed points spaced down the left edge, each integrated 300 Euler steps of 0.02 s through a frozen field of four alternating ±52000 vortices at x = 0.30/0.44/0.58/0.72 of width alternating between 0.44 h and 0.56 h (ambient boosted 2.4×), stroked as thin 1 px --foreground curves at alpha 0.32 with --ns-muted ring hints at the four centers. No SVG, no dependencies.

Props

PropTypeDefaultDescription
className?string—extra classes merged onto the rendered root element