{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "flow-field",
  "type": "registry:ui",
  "title": "Flow Field",
  "description": "Particles drifting along an evolving simplex-noise vector field, leaving soft fading trails.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/flow-field.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type FlowFieldPointer = \"vortex\" | \"repel\" | \"none\";\n\nexport interface FlowFieldProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of particles before adaptive reduction. */\n  particleCount?: number;\n  /** Drift speed multiplier. 0 freezes the particles (the field still renders). */\n  speed?: number;\n  /** Field frequency: higher values give tighter, busier swirls. */\n  noiseScale?: number;\n  /** Trail length, in recorded positions per particle. */\n  trail?: number;\n  /** Trail stroke width, in px. */\n  lineWidth?: number;\n  /** Main particle colour (any CSS colour). */\n  color?: string;\n  /** Second palette colour, used by one particle in three (any CSS colour). */\n  secondaryColor?: string;\n  /** How particles react to a mouse pointer. */\n  pointer?: FlowFieldPointer;\n  /** Scale the particle count with the container area and shed particles when frames drop. */\n  adaptive?: boolean;\n}\n\ntype RGB = [number, number, number];\n\n/* ---------------------------------- Colour --------------------------------- */\n\nlet probe: CanvasRenderingContext2D | null = null;\n\n/** Resolves any CSS colour (hex, rgb(), oklch()…) to RGB by painting it on a 1×1 canvas. */\nfunction resolveColor(input: string, fallback: RGB): RGB {\n  probe ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true });\n  if (!probe) return fallback;\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = \"rgba(0, 0, 0, 0)\";\n  probe.fillStyle = input; // invalid colours are ignored → transparent → fallback\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b, a] = probe.getImageData(0, 0, 1, 1).data;\n  return a === 0 ? fallback : [r, g, b];\n}\n\nconst luminance = ([r, g, b]: RGB) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;\n\n/** On light themes (dark ink), very light colours are pulled toward the ink so they stay visible. */\nfunction legible(color: RGB, ink: RGB): RGB {\n  if (luminance(ink) > 0.5 || luminance(color) < 0.62) return color;\n  return color.map((v, i) => Math.round(v + (ink[i] - v) * 0.42)) as RGB;\n}\n\n/** Re-reads colours when the theme changes: class/data-theme on <html> or a scoped [data-theme] ancestor. */\nfunction observeTheme(el: Element, onChange: () => void) {\n  const mo = new MutationObserver(onChange);\n  const options = { attributes: true, attributeFilter: [\"class\", \"data-theme\"] };\n  mo.observe(document.documentElement, options);\n  const scoped = el.parentElement?.closest(\"[data-theme]\");\n  if (scoped && scoped !== document.documentElement) mo.observe(scoped, options);\n  const mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n  mq.addEventListener(\"change\", onChange);\n  return () => {\n    mo.disconnect();\n    mq.removeEventListener(\"change\", onChange);\n  };\n}\n\n/* ---------------------------------- Noise ---------------------------------- */\n\n/** Compact seeded 3D simplex noise (after Stefan Gustavson). Returns values in roughly [-1, 1]. */\nfunction createNoise3D(seed = 7) {\n  const p = new Uint8Array(256);\n  for (let i = 0; i < 256; i++) p[i] = i;\n  let s = seed >>> 0 || 1;\n  for (let i = 255; i > 0; i--) {\n    s = (Math.imul(s, 1664525) + 1013904223) >>> 0;\n    const j = s % (i + 1);\n    const t = p[i];\n    p[i] = p[j];\n    p[j] = t;\n  }\n  const perm = new Uint8Array(512);\n  for (let i = 0; i < 512; i++) perm[i] = p[i & 255];\n  const grad = [1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1, 0, 1, 0, 1, -1, 0, 1, 1, 0, -1, -1, 0, -1, 0, 1, 1, 0, -1, 1, 0, 1, -1, 0, -1, -1];\n  const F3 = 1 / 3;\n  const G3 = 1 / 6;\n\n  const corner = (hash: number, x: number, y: number, z: number) => {\n    let t = 0.6 - x * x - y * y - z * z;\n    if (t < 0) return 0;\n    const g = (hash % 12) * 3;\n    t *= t;\n    return t * t * (grad[g] * x + grad[g + 1] * y + grad[g + 2] * z);\n  };\n\n  return (x: number, y: number, z: number) => {\n    const sk = (x + y + z) * F3;\n    const i = Math.floor(x + sk);\n    const j = Math.floor(y + sk);\n    const k = Math.floor(z + sk);\n    const t = (i + j + k) * G3;\n    const x0 = x - (i - t);\n    const y0 = y - (j - t);\n    const z0 = z - (k - t);\n    let i1, j1, k1, i2, j2, k2;\n    if (x0 >= y0) {\n      if (y0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 1, 0];\n      else if (x0 >= z0) [i1, j1, k1, i2, j2, k2] = [1, 0, 0, 1, 0, 1];\n      else [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 1, 0, 1];\n    } else if (y0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 0, 1, 0, 1, 1];\n    else if (x0 < z0) [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 0, 1, 1];\n    else [i1, j1, k1, i2, j2, k2] = [0, 1, 0, 1, 1, 0];\n    const ii = i & 255;\n    const jj = j & 255;\n    const kk = k & 255;\n    const n0 = corner(perm[ii + perm[jj + perm[kk]]], x0, y0, z0);\n    const n1 = corner(perm[ii + i1 + perm[jj + j1 + perm[kk + k1]]], x0 - i1 + G3, y0 - j1 + G3, z0 - k1 + G3);\n    const n2 = corner(perm[ii + i2 + perm[jj + j2 + perm[kk + k2]]], x0 - i2 + 2 * G3, y0 - j2 + 2 * G3, z0 - k2 + 2 * G3);\n    const n3 = corner(perm[ii + 1 + perm[jj + 1 + perm[kk + 1]]], x0 - 0.5, y0 - 0.5, z0 - 0.5);\n    return 32 * (n0 + n1 + n2 + n3);\n  };\n}\n\n/* ----------------------------- Reduced motion ------------------------------ */\n\nfunction subscribeReducedMotion(callback: () => void) {\n  const mq = window.matchMedia(\"(prefers-reduced-motion: reduce)\");\n  mq.addEventListener(\"change\", callback);\n  return () => mq.removeEventListener(\"change\", callback);\n}\n\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* -------------------------------- Component -------------------------------- */\n\nconst TAU = Math.PI * 2;\nconst REFERENCE_AREA = 1280 * 720;\nconst POINTER_RADIUS = 180;\nconst ALPHA_BUCKETS = 8;\nconst STEP_MS = 1000 / 60;\n\n/**\n * Particles drifting along a slowly evolving simplex-noise vector field, drawn as fading\n * polyline trails on one canvas. Trails are rebuilt from a per-particle history every frame,\n * so the canvas stays transparent (no smearing, no ghost pixels) and follows theme changes.\n */\nexport function FlowField({\n  particleCount = 1200,\n  speed = 1,\n  noiseScale = 3,\n  trail = 20,\n  lineWidth = 1,\n  color = \"#FF7448\",\n  secondaryColor = \"#B6F3D1\",\n  pointer = \"vortex\",\n  adaptive = true,\n  className = \"\",\n  children,\n  ...props\n}: FlowFieldProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!canvas || !ctx) return;\n\n    const noise = createNoise3D(11);\n    const length = Math.max(2, Math.round(trail));\n    const frequency = Math.max(0.1, noiseScale) / 1000;\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let fieldTime = 0;\n    let head = 0;\n    let raf = 0;\n    let last = 0;\n    let inView = false;\n    let sampleTime = 0;\n    let sampleFrames = 0;\n    let pending = 0; // simulated time not yet stepped, in ms\n    let palette: [string[], string[]] = [[], []];\n\n    // Particle state in flat typed arrays. `hist` is a ring buffer of `length` positions per particle.\n    let count = 0;\n    let target = 0;\n    let px = new Float32Array(0);\n    let py = new Float32Array(0);\n    let life = new Float32Array(0);\n    let age = new Uint8Array(0);\n    let hist = new Float32Array(0);\n\n    const ptr = { clientX: 0, clientY: 0, x: 0, y: 0, inside: false, strength: 0 };\n\n    const spawn = (i: number) => {\n      px[i] = Math.random() * width;\n      py[i] = Math.random() * height;\n      life[i] = 90 + Math.random() * 320;\n      age[i] = 0;\n    };\n\n    const allocate = (next: number) => {\n      count = next;\n      px = new Float32Array(count);\n      py = new Float32Array(count);\n      life = new Float32Array(count);\n      age = new Uint8Array(count);\n      hist = new Float32Array(count * length * 2);\n      for (let i = 0; i < count; i++) {\n        spawn(i);\n        // Stagger lifetimes so particles do not all respawn together.\n        life[i] *= Math.random();\n      }\n    };\n\n    const readTheme = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      const colors = [legible(resolveColor(color, [255, 116, 72]), ink), legible(resolveColor(secondaryColor, [182, 243, 209]), ink)];\n      palette = colors.map((c) =>\n        Array.from({ length: ALPHA_BUCKETS }, (_, b) => {\n          // Bucket 0 holds the newest segments; alpha falls off toward the tail.\n          const a = 0.85 * Math.pow(1 - b / ALPHA_BUCKETS, 1.1);\n          return `rgba(${c[0]}, ${c[1]}, ${c[2]}, ${a.toFixed(3)})`;\n        }),\n      ) as [string[], string[]];\n    };\n\n    /** Advances every particle by one step (`f` = frames at 60fps). */\n    const step = (f: number) => {\n      head = (head + 1) % length;\n      const move = 1.6 * speed * f;\n      const reach = POINTER_RADIUS;\n      const interact = pointer !== \"none\" && ptr.strength > 0.01;\n      for (let i = 0; i < count; i++) {\n        let x = px[i];\n        let y = py[i];\n        const angle = noise(x * frequency, y * frequency, fieldTime) * TAU;\n        let vx = Math.cos(angle);\n        let vy = Math.sin(angle);\n\n        if (interact) {\n          const dx = x - ptr.x;\n          const dy = y - ptr.y;\n          const d = Math.sqrt(dx * dx + dy * dy) || 1;\n          if (d < reach) {\n            const fall = (1 - d / reach) * (1 - d / reach) * ptr.strength;\n            if (pointer === \"vortex\") {\n              // Mostly tangential flow with a slight pull inward: a gentle whirlpool.\n              vx += (-dy / d) * fall * 2.4 - (dx / d) * fall * 0.35;\n              vy += (dx / d) * fall * 2.4 - (dy / d) * fall * 0.35;\n            } else {\n              vx += (dx / d) * fall * 2.8;\n              vy += (dy / d) * fall * 2.8;\n            }\n          }\n        }\n\n        x += vx * move;\n        y += vy * move;\n        life[i] -= f;\n        if (life[i] <= 0 || x < -2 || y < -2 || x > width + 2 || y > height + 2) {\n          spawn(i);\n          x = px[i];\n          y = py[i];\n        }\n        px[i] = x;\n        py[i] = y;\n        const slot = (i * length + head) * 2;\n        hist[slot] = x;\n        hist[slot + 1] = y;\n        if (age[i] < length) age[i]++;\n      }\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      ctx.lineWidth = lineWidth;\n      ctx.lineCap = \"round\";\n      ctx.lineJoin = \"round\";\n      const segments = length - 1;\n      const buckets = Math.min(ALPHA_BUCKETS, segments);\n      for (let tone = 0; tone < 2; tone++) {\n        for (let b = 0; b < buckets; b++) {\n          // Segment range [j0, j1] (0 = newest) covered by this alpha bucket.\n          const j0 = Math.floor((b * segments) / buckets);\n          const j1 = Math.floor(((b + 1) * segments) / buckets);\n          ctx.beginPath();\n          for (let i = 0; i < count; i++) {\n            // Every third particle uses the secondary colour.\n            if ((i % 3 === 0 ? 1 : 0) !== tone) continue;\n            const valid = age[i] - 1; // number of drawable segments\n            if (valid <= j0) continue;\n            const end = Math.min(j1, valid);\n            const base = i * length;\n            let slot = (base + ((head - end + length * 2) % length)) * 2;\n            ctx.moveTo(hist[slot], hist[slot + 1]);\n            for (let j = end - 1; j >= j0; j--) {\n              slot = (base + ((head - j + length * 2) % length)) * 2;\n              ctx.lineTo(hist[slot], hist[slot + 1]);\n            }\n          }\n          ctx.strokeStyle = palette[tone][Math.round((b / buckets) * (ALPHA_BUCKETS - 1))];\n          ctx.stroke();\n        }\n      }\n    };\n\n    /** Reduced motion: pre-run the simulation so the still frame shows full streamlines. */\n    const drawStill = () => {\n      for (let n = 0; n < length; n++) step(1);\n      draw();\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : 16.7);\n      last = now;\n      const f = dt / 16.7;\n\n      if (ptr.inside) {\n        const rect = canvas.getBoundingClientRect();\n        ptr.x = ptr.clientX - rect.left;\n        ptr.y = ptr.clientY - rect.top;\n      }\n      ptr.strength += ((ptr.inside ? 1 : 0) - ptr.strength) * (1 - Math.pow(0.9, f));\n      fieldTime += dt * 0.00006 * Math.max(0.15, speed);\n\n      // Fixed 60Hz simulation steps, so trail length (in px) is the same on 60, 120 or 144Hz displays.\n      pending = Math.min(pending + dt, STEP_MS * 4);\n      let stepped = false;\n      while (pending >= STEP_MS) {\n        step(1);\n        pending -= STEP_MS;\n        stepped = true;\n      }\n      if (stepped) draw();\n\n      if (adaptive) {\n        sampleTime += dt;\n        sampleFrames++;\n        if (sampleFrames >= 90) {\n          // Below ~40fps: shed a quarter of the particles (never below 30% of the target).\n          if (sampleTime / sampleFrames > 25 && count > target * 0.3) {\n            count = Math.round(count * 0.75);\n          }\n          sampleTime = 0;\n          sampleFrames = 0;\n        }\n      }\n      schedule();\n    };\n\n    const schedule = () => {\n      if (!raf && inView && !reduceMotion && !document.hidden) raf = requestAnimationFrame(frame);\n    };\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && count > 0) return;\n      width = rect.width;\n      height = rect.height;\n      dpr = Math.min(2, window.devicePixelRatio || 1);\n      canvas.width = Math.max(1, Math.round(width * dpr));\n      canvas.height = Math.max(1, Math.round(height * dpr));\n      const areaScale = adaptive ? Math.min(1, Math.max(0.3, (width * height) / REFERENCE_AREA)) : 1;\n      target = Math.max(40, Math.round(particleCount * areaScale));\n      allocate(target);\n      if (reduceMotion) drawStill();\n      else draw();\n      schedule();\n    };\n\n    const onThemeChange = () => {\n      readTheme();\n      if (!raf) draw();\n    };\n\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && e.pointerType !== \"pen\") return;\n      const rect = canvas.getBoundingClientRect();\n      ptr.clientX = e.clientX;\n      ptr.clientY = e.clientY;\n      const inside = e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom;\n      if (inside && !ptr.inside && ptr.strength < 0.01) {\n        ptr.x = e.clientX - rect.left;\n        ptr.y = e.clientY - rect.top;\n      }\n      ptr.inside = inside;\n    };\n    const onPointerLeave = () => {\n      ptr.inside = false;\n    };\n\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    // Pointer tracking lives on the window (only while visible), so content layered above\n    // the field never blocks it and the field never blocks the content.\n    const listenPointer = pointer !== \"none\" && !reduceMotion;\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) {\n        if (listenPointer) {\n          window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n          document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n        }\n        schedule();\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n        ptr.inside = false;\n        stop();\n      }\n    });\n\n    readTheme();\n    resize();\n    const ro = new ResizeObserver(resize);\n    ro.observe(canvas);\n    io.observe(canvas);\n    const unobserveTheme = observeTheme(canvas, onThemeChange);\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      window.removeEventListener(\"pointermove\", onPointerMove);\n      document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n    };\n  }, [particleCount, speed, noiseScale, trail, lineWidth, color, secondaryColor, pointer, adaptive, reduceMotion]);\n\n  return (\n    <div className={`absolute inset-0 overflow-hidden ${className}`} {...props}>\n      <canvas\n        ref={canvasRef}\n        aria-hidden=\"true\"\n        className=\"pointer-events-none absolute inset-0 block h-full w-full text-zinc-900 dark:text-zinc-100\"\n      />\n      {children != null && <div className=\"relative h-full w-full\">{children}</div>}\n    </div>\n  );\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "backgrounds"
  ],
  "docs": "Documentation: https://wancraft.my/components/flow-field"
}
