Hero Vortex Street

Fluidloud

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.

Install
npx shadcn add https://design.helpmarq.com/r/hero-vortex-street.json
Source
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 {
  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("--muted").trim();
      const bg = cs.getPropertyValue("--background").trim();
      const accent = cs.getPropertyValue("--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>
  );
}
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 --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 --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 --muted ring hints at the four centers. No SVG, no dependencies.

Tags
fluidvortexcursorcanvasparticlesphysicsherodecorative