{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "fireflies",
  "type": "registry:ui",
  "title": "Fireflies",
  "description": "Glowing fireflies drifting on smooth noise paths, blinking on their own rhythms and gently drawn toward the pointer.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/fireflies.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport interface FirefliesProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Number of fireflies. */\n  count?: number;\n  /** Glow colour (any CSS colour). Deepened to a darker amber-like tone on light themes. */\n  color?: string;\n  /** Drift speed multiplier. 0 holds them in place (they still blink). */\n  speed?: number;\n  /** Glow diameter of the nearest fireflies, in px. Far ones are about 40% of this. */\n  size?: number;\n  /** How strongly they drift toward the pointer, 0–1. 0 ignores the pointer. */\n  attraction?: number;\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;\nconst mix = (a: RGB, b: RGB, t: number) => a.map((v, i) => Math.round(v + (b[i] - v) * t)) as RGB;\nconst rgba = ([r, g, b]: RGB, a: number) => `rgba(${r}, ${g}, ${b}, ${a})`;\n\n/**\n * Deepens a colour for light themes: a per-channel gamma keeps the dominant channel and drops the\n * weaker ones (pale yellow → amber, not olive), then it is scaled down to a target luminance.\n */\nfunction deepen(c: RGB, target: number): RGB {\n  const rich = c.map((v) => 255 * Math.pow(v / 255, 2.2)) as RGB;\n  const lum = luminance(rich);\n  const k = lum > target ? target / lum : 1;\n  return rich.map((v) => Math.round(v * k)) 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/* ----------------------------- 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\n/** SSR-safe: hydrates as \"no preference\", then follows the media query. */\nfunction usePrefersReducedMotion() {\n  return React.useSyncExternalStore(\n    subscribeReducedMotion,\n    () => window.matchMedia(\"(prefers-reduced-motion: reduce)\").matches,\n    () => false,\n  );\n}\n\n/* --------------------------------- Helpers --------------------------------- */\n\n/** mulberry32: a tiny seeded PRNG, so the reduced-motion still is the same on every visit. */\nfunction seeded(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/** Smooth 1D value noise in [-1, 1]: hashed lattice values joined by a smoothstep. */\nfunction noise(x: number) {\n  const i = Math.floor(x);\n  const f = x - i;\n  const hash = (n: number) => {\n    const s = Math.sin(n * 127.1 + 311.7) * 43758.5453;\n    return (s - Math.floor(s)) * 2 - 1;\n  };\n  const u = f * f * (3 - 2 * f);\n  return hash(i) + (hash(i + 1) - hash(i)) * u;\n}\n\n/* --------------------------------- Sprite ---------------------------------- */\n\nconst SPRITE = 96;\n\n/**\n * One radial-gradient glow, rendered once per colour and theme and then drawn scaled with\n * drawImage — no per-frame blur. Dark themes get a hot pale core; light themes a dark amber\n * body with a softer halo.\n */\nfunction renderSprite(glow: RGB, core: RGB, dark: boolean) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = SPRITE;\n  canvas.height = SPRITE;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return canvas;\n  const c = SPRITE / 2;\n  const g = ctx.createRadialGradient(c, c, 0, c, c, c);\n  if (dark) {\n    g.addColorStop(0, rgba(core, 1));\n    g.addColorStop(0.07, rgba(core, 0.95));\n    g.addColorStop(0.14, rgba(glow, 0.85));\n    g.addColorStop(0.32, rgba(glow, 0.3));\n    g.addColorStop(0.6, rgba(glow, 0.08));\n    g.addColorStop(1, rgba(glow, 0));\n  } else {\n    g.addColorStop(0, rgba(core, 1));\n    g.addColorStop(0.08, rgba(core, 0.9));\n    g.addColorStop(0.16, rgba(glow, 0.62));\n    g.addColorStop(0.36, rgba(glow, 0.22));\n    g.addColorStop(0.65, rgba(glow, 0.05));\n    g.addColorStop(1, rgba(glow, 0));\n  }\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, SPRITE, SPRITE);\n  return canvas;\n}\n\n/* -------------------------------- Component -------------------------------- */\n\nconst STEP_MS = 1000 / 60;\nconst PULL_RADIUS = 260;\n\ninterface Fly {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  /** 0 = far, 1 = near. */\n  depth: number;\n  /** Offset into the steering noise. */\n  seed: number;\n  /** Blink rhythm: period (s), phase (0–1) and the lit share of each cycle. */\n  period: number;\n  phase: number;\n  lit: number;\n}\n\n/**\n * Fireflies drifting on smooth noise paths and blinking on their own rhythms. Depth sets size,\n * brightness and speed; a mouse or pen pointer gently draws them in (they circle rather than\n * pile up). Glows are pre-rendered sprites and the canvas pauses offscreen; reduced motion\n * shows a still scene.\n */\nexport function Fireflies({\n  count = 36,\n  color = \"#FFC65C\",\n  speed = 1,\n  size = 26,\n  attraction = 0.5,\n  className = \"\",\n  children,\n  ...props\n}: FirefliesProps) {\n  const reduceMotion = usePrefersReducedMotion();\n  const rootRef = React.useRef<HTMLDivElement>(null);\n  const canvasRef = React.useRef<HTMLCanvasElement>(null);\n\n  React.useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const ctx = canvas?.getContext(\"2d\");\n    if (!root || !canvas || !ctx) return;\n\n    const total = Math.max(1, Math.round(count));\n    const glowSize = Math.max(6, size);\n    const pull = Math.min(1, Math.max(0, attraction));\n    const rnd = reduceMotion ? seeded(1729 + total) : Math.random;\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let raf = 0;\n    let last = 0;\n    let pending = 0;\n    let time = rnd() * 100; // seconds, drives blinking and steering noise\n    let inView = false;\n    let dark = true;\n    let sprite: HTMLCanvasElement | null = null;\n    const ptr = { x: 0, y: 0, inside: false, touch: false };\n\n    // Sorted far → near so nearer fireflies draw on top.\n    const flies: Fly[] = Array.from({ length: total }, () => ({\n      x: 0,\n      y: 0,\n      vx: 0,\n      vy: 0,\n      depth: rnd(),\n      seed: rnd() * 1000,\n      period: 2.4 + rnd() * 4.2,\n      phase: rnd(),\n      lit: 0.28 + rnd() * 0.2,\n    })).sort((a, b) => a.depth - b.depth);\n\n    const buildSprite = () => {\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      dark = luminance(ink) > 0.5;\n      const base = resolveColor(color, [255, 198, 92]);\n      sprite = dark\n        ? renderSprite(base, mix(base, [255, 255, 255], 0.65), true)\n        : renderSprite(deepen(base, 0.48), deepen(base, 0.32), false);\n    };\n\n    /** Soft blink: a smooth rise and fall during the lit share of the cycle, a faint ember otherwise. */\n    const glowOf = (f: Fly) => {\n      // The reduced-motion still shows a designed mix of bright and dim fireflies instead of a\n      // random moment in which most of them happen to be dark.\n      if (reduceMotion) return 0.3 + 0.7 * ((f.phase * 7.31) % 1);\n      const p = (time / f.period + f.phase) % 1;\n      const on = p < f.lit ? Math.sin((Math.PI * p) / f.lit) : 0;\n      return 0.1 + 0.9 * on * on;\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      if (!sprite) return;\n      ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n      for (const f of flies) {\n        const s = glowSize * (0.4 + 0.6 * f.depth) * 2.2; // the sprite's visible glow is ~45% of its box\n        ctx.globalAlpha = glowOf(f) * (0.45 + 0.55 * f.depth);\n        ctx.drawImage(sprite, f.x - s / 2, f.y - s / 2, s, s);\n      }\n      ctx.globalAlpha = 1;\n      ctx.globalCompositeOperation = \"source-over\";\n    };\n\n    /** One fixed 60Hz step of steering, pointer pull and wrapping. */\n    const step = () => {\n      time += STEP_MS / 1000;\n      const margin = glowSize;\n      for (const f of flies) {\n        const pace = (0.25 + 0.45 * f.depth) * speed;\n        // Heading wanders along smooth noise; each firefly reads its own stretch of it.\n        const heading = f.seed + noise(f.seed + time * 0.18) * Math.PI * 1.6;\n        let ax = (Math.cos(heading) * pace - f.vx) * 0.03;\n        let ay = (Math.sin(heading) * pace * 0.7 - f.vy) * 0.03;\n\n        if (ptr.inside && pull > 0 && speed > 0) {\n          const dx = ptr.x - f.x;\n          const dy = ptr.y - f.y;\n          const d = Math.hypot(dx, dy) || 1;\n          if (d < PULL_RADIUS) {\n            const k = (1 - d / PULL_RADIUS) * pull * 0.05 * (0.5 + f.depth * 0.5);\n            const toward = d > 34 ? 1 : -0.6; // keep a little room around the pointer\n            // Toward the pointer, plus a sideways swirl so they circle instead of clumping.\n            ax += (dx / d) * k * toward - (dy / d) * k * 0.7;\n            ay += (dy / d) * k * toward + (dx / d) * k * 0.7;\n          }\n        }\n\n        f.vx = (f.vx + ax) * 0.985;\n        f.vy = (f.vy + ay) * 0.985;\n        f.x += f.vx;\n        f.y += f.vy;\n        if (f.x < -margin) f.x = width + margin;\n        else if (f.x > width + margin) f.x = -margin;\n        if (f.y < -margin) f.y = height + margin;\n        else if (f.y > height + margin) f.y = -margin;\n      }\n    };\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n      // Fixed 60Hz steps keep drift and pull identical on 60, 120 or 144Hz displays; a short frame\n      // may run no step, and simply redraws the same state. The scene never settles (it blinks).\n      pending = Math.min(pending + dt, STEP_MS * 4);\n      let stepped = false;\n      while (pending >= STEP_MS) {\n        step();\n        pending -= STEP_MS;\n        stepped = true;\n      }\n      if (stepped) draw();\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 && width > 0) return;\n      const first = width === 0;\n      const sx = width ? rect.width / width : 1;\n      const sy = height ? rect.height / height : 1;\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      for (const f of flies) {\n        if (first) {\n          f.x = rnd() * width;\n          f.y = rnd() * height;\n        } else {\n          f.x *= sx;\n          f.y *= sy;\n        }\n      }\n      draw();\n      schedule();\n    };\n\n    const locate = (e: PointerEvent) => {\n      const rect = canvas.getBoundingClientRect();\n      ptr.x = e.clientX - rect.left;\n      ptr.y = e.clientY - rect.top;\n      return ptr.x >= 0 && ptr.y >= 0 && ptr.x <= rect.width && ptr.y <= rect.height;\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType === \"touch\" && !ptr.touch) return;\n      ptr.inside = locate(e);\n    };\n    // Touch has no hover: they gather toward a finger only while it is pressed.\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      ptr.touch = true;\n      ptr.inside = locate(e);\n    };\n    const onPointerUp = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      ptr.touch = false;\n      ptr.inside = false;\n    };\n    const onPointerLeave = () => (ptr.inside = false);\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n    const onThemeChange = () => {\n      buildSprite();\n      if (!raf) draw();\n    };\n\n    // Pointer listeners only exist while the canvas is on screen.\n    const listen = (on: boolean) => {\n      if (on) {\n        window.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n        root.addEventListener(\"pointerdown\", onPointerDown);\n        window.addEventListener(\"pointerup\", onPointerUp);\n        window.addEventListener(\"pointercancel\", onPointerUp);\n        document.documentElement.addEventListener(\"pointerleave\", onPointerLeave);\n      } else {\n        window.removeEventListener(\"pointermove\", onPointerMove);\n        root.removeEventListener(\"pointerdown\", onPointerDown);\n        window.removeEventListener(\"pointerup\", onPointerUp);\n        window.removeEventListener(\"pointercancel\", onPointerUp);\n        document.documentElement.removeEventListener(\"pointerleave\", onPointerLeave);\n      }\n    };\n    const listenPointer = pull > 0 && !reduceMotion;\n    let listening = false;\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (listenPointer && inView !== listening) {\n        listening = inView;\n        listen(inView);\n      }\n      if (inView) schedule();\n      else stop();\n    });\n\n    buildSprite();\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      if (listening) listen(false);\n    };\n  }, [count, color, speed, size, attraction, reduceMotion]);\n\n  return (\n    <div ref={rootRef} 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/fireflies"
}
