{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "weather-effect",
  "type": "registry:ui",
  "title": "Weather Effect",
  "description": "Rain, snow or sleet over content: wind-angled streaks that splash, drifting flakes with sway and depth, and a pointer gust.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/weather-effect.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type WeatherType = \"rain\" | \"snow\" | \"sleet\";\n\nexport interface WeatherEffectProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Rain streaks, drifting snow, or sleet (short streaks mixed with ice pellets). */\n  type?: WeatherType;\n  /** Density of the precipitation, 0.1–1. Scales with the container's area. */\n  intensity?: number;\n  /** Wind, -1 (blowing left) to 1 (blowing right). */\n  wind?: number;\n  /** Fall speed multiplier. */\n  speed?: number;\n  /** Moving a mouse or pen pointer pushes nearby particles along with it. */\n  gust?: boolean;\n  /** Raindrops and pellets splash at the bottom edge. */\n  splash?: 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;\nconst rgba = ([r, g, b]: RGB, a: number) => `rgba(${r}, ${g}, ${b}, ${a.toFixed(3)})`;\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/** 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/* -------------------------------- Particles -------------------------------- */\n\nconst STEP_MS = 1000 / 60;\n/** Particles per 100,000 px² at intensity 1, by kind of weather. */\nconst DENSITY: Record<WeatherType, number> = { rain: 120, snow: 64, sleet: 96 };\nconst MAX_PARTICLES = 1100;\nconst MAX_SPLASHES = 320;\nconst GUST_RADIUS = 200;\nconst FLAKE_SPRITE = 32;\n\ntype Kind = \"drop\" | \"flake\" | \"pellet\";\n\ninterface Particle {\n  kind: Kind;\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  /** 0 = far, 1 = near: size, speed, opacity and whether it splashes. */\n  z: number;\n  /** Sway phase and rate (snow). */\n  phase: number;\n  rate: number;\n}\n\ninterface Splash {\n  x: number;\n  y: number;\n  vx: number;\n  vy: number;\n  life: number;\n  /** Rings spread flat on the ground; droplets arc up and fall. */\n  ring: boolean;\n  size: number;\n}\n\n/** A soft round flake, rendered once per theme and drawn scaled. */\nfunction renderFlake(ink: RGB) {\n  const canvas = document.createElement(\"canvas\");\n  canvas.width = FLAKE_SPRITE;\n  canvas.height = FLAKE_SPRITE;\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return canvas;\n  const c = FLAKE_SPRITE / 2;\n  const g = ctx.createRadialGradient(c, c, 0, c, c, c);\n  g.addColorStop(0, rgba(ink, 1));\n  g.addColorStop(0.45, rgba(ink, 0.85));\n  g.addColorStop(0.75, rgba(ink, 0.25));\n  g.addColorStop(1, rgba(ink, 0));\n  ctx.fillStyle = g;\n  ctx.fillRect(0, 0, FLAKE_SPRITE, FLAKE_SPRITE);\n  return canvas;\n}\n\n/* -------------------------------- Component -------------------------------- */\n\n/**\n * Rain, snow or sleet falling over content. Rain streaks lean with the wind and splash at the\n * bottom, snow drifts and sways at different depths, and a moving pointer pushes nearby\n * particles in a gentle gust. The particle count scales with the container's area (fewer on\n * small screens); the canvas pauses offscreen and in hidden tabs, and reduced motion draws a\n * still frame.\n */\nexport function WeatherEffect({\n  type = \"rain\",\n  intensity = 0.5,\n  wind = 0.25,\n  speed = 1,\n  gust = true,\n  splash = true,\n  className = \"\",\n  children,\n  ...props\n}: WeatherEffectProps) {\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 density = Math.min(1, Math.max(0.1, intensity));\n    const windX = Math.min(1, Math.max(-1, wind));\n    const fall = Math.min(3, Math.max(0.1, speed));\n    const rnd = reduceMotion ? seeded(97 + Math.round(density * 100)) : 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 = 0;\n    let inView = false;\n    let dark = true;\n    let ink: RGB = [148, 163, 184];\n    let flake: HTMLCanvasElement | null = null;\n    let particles: Particle[] = [];\n    const splashes: Splash[] = [];\n    // Pointer position and its smoothed horizontal velocity (px per step), for the gust.\n    const ptr = { x: 0, y: 0, vx: 0, lastX: 0, inside: false, touch: false, seen: false };\n\n    const readTheme = () => {\n      const probed = resolveColor(getComputedStyle(canvas).color, [148, 163, 184]);\n      dark = luminance(probed) > 0.5;\n      ink = probed;\n      // Flakes on a light ground read as soft grey-blue rather than dark dots.\n      flake = renderFlake(dark ? ink : (ink.map((v) => Math.round(v + (255 - v) * 0.3)) as RGB));\n    };\n\n    const kindFor = (): Kind => (type === \"snow\" ? \"flake\" : type === \"sleet\" && rnd() < 0.45 ? \"pellet\" : \"drop\");\n\n    /** Terminal fall speed in px per step. */\n    const fallSpeed = (p: Particle) =>\n      (p.kind === \"drop\" ? (type === \"sleet\" ? 7 + 6 * p.z : 9 + 10 * p.z) : p.kind === \"pellet\" ? 5 + 4 * p.z : 0.45 + 1.05 * p.z) * fall;\n\n    /** Horizontal drift the wind gives a particle: heavy drops lean less than flakes. */\n    const windDrift = (p: Particle) => windX * (p.kind === \"flake\" ? 0.7 + 1.6 * p.z : fallSpeed(p) * 0.55);\n\n    /** Places a particle above the top edge, upwind enough that wind-blown ones cover the whole box. */\n    const spawn = (p: Particle, anywhere: boolean) => {\n      const lean = height * (windDrift(p) / Math.max(0.3, fallSpeed(p)));\n      p.x = rnd() * (width + Math.abs(lean)) - Math.max(0, lean);\n      p.y = anywhere ? rnd() * height : -10 - rnd() * 60;\n      p.vx = windDrift(p);\n      p.vy = fallSpeed(p);\n    };\n\n    const populate = () => {\n      const area = width * height;\n      const small = width < 640 ? 0.7 : 1; // adaptive: phones get a lighter field\n      const count = Math.min(MAX_PARTICLES, Math.max(8, Math.round((area / 100_000) * DENSITY[type] * density * small)));\n      particles = Array.from({ length: count }, () => {\n        const p: Particle = { kind: kindFor(), x: 0, y: 0, vx: 0, vy: 0, z: rnd(), phase: rnd() * Math.PI * 2, rate: 0.6 + rnd() * 0.9 };\n        spawn(p, true);\n        return p;\n      });\n      splashes.length = 0;\n    };\n\n    const addSplash = (p: Particle, groundY: number) => {\n      if (!splash || splashes.length > MAX_SPLASHES) return;\n      if (p.kind === \"drop\") {\n        splashes.push({ x: p.x, y: groundY, vx: 0, vy: 0, life: 1, ring: true, size: 4 + p.z * 7 });\n        const n = 1 + Math.floor(rnd() * 3);\n        for (let i = 0; i < n; i++) {\n          splashes.push({ x: p.x, y: groundY, vx: (rnd() - 0.5) * 2.4 + p.vx * 0.15, vy: -(1.4 + rnd() * 2) * (0.6 + 0.5 * p.z), life: 1, ring: false, size: 1 + p.z * 0.6 });\n        }\n      } else {\n        // Ice pellets bounce once.\n        splashes.push({ x: p.x, y: groundY, vx: p.vx * 0.3 + (rnd() - 0.5) * 1.5, vy: -(2 + rnd() * 2) * (0.5 + 0.5 * p.z), life: 1, ring: false, size: 1.2 + p.z });\n      }\n    };\n\n    /** One fixed 60Hz step. */\n    const step = () => {\n      time += STEP_MS / 1000;\n      ptr.vx *= 0.9;\n      for (const p of particles) {\n        let targetVx = windDrift(p);\n        if (p.kind === \"flake\") targetVx += Math.sin(time * p.rate + p.phase) * (0.35 + 0.5 * p.z); // sway\n        // Gust: particles near a moving pointer are pushed along with it, lighter ones more.\n        if (gust && ptr.inside && Math.abs(ptr.vx) > 0.05) {\n          const dx = p.x - ptr.x;\n          const dy = p.y - ptr.y;\n          const d2 = dx * dx + dy * dy;\n          if (d2 < GUST_RADIUS * GUST_RADIUS) {\n            const falloff = 1 - Math.sqrt(d2) / GUST_RADIUS;\n            p.vx += ptr.vx * falloff * (p.kind === \"flake\" ? 0.06 : 0.025);\n          }\n        }\n        p.vx += (targetVx - p.vx) * (p.kind === \"flake\" ? 0.02 : 0.08);\n        p.vy += (fallSpeed(p) - p.vy) * 0.1;\n        p.x += p.vx;\n        p.y += p.vy;\n\n        // Near drops and pellets land on a shallow ground band at the bottom; far ones fall past.\n        const lands = p.kind !== \"flake\" && p.z > 0.5;\n        const groundY = height - 2 - (1 - p.z) * 22;\n        if (lands && p.y >= groundY) {\n          addSplash(p, groundY);\n          spawn(p, false);\n        } else if (p.y > height + 40) spawn(p, false);\n        else if (p.x < -60) p.x += width + 120;\n        else if (p.x > width + 60) p.x -= width + 120;\n      }\n\n      for (let i = splashes.length - 1; i >= 0; i--) {\n        const s = splashes[i];\n        if (s.ring) s.life -= 0.06;\n        else {\n          s.vy += 0.22;\n          s.x += s.vx;\n          s.y += s.vy;\n          s.life -= 0.035;\n        }\n        if (s.life <= 0) splashes.splice(i, 1);\n      }\n    };\n\n    const draw = () => {\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.clearRect(0, 0, width, height);\n      ctx.lineCap = \"round\";\n\n      // Streaks in three depth buckets, one path each: far ones thin and faint.\n      const buckets = [new Path2D(), new Path2D(), new Path2D()];\n      const pellets = new Path2D();\n      for (const p of particles) {\n        if (p.kind === \"drop\") {\n          const v = Math.hypot(p.vx, p.vy) || 1;\n          const len = (type === \"sleet\" ? 0.9 : 1.5) * v * (0.7 + 0.6 * p.z);\n          const path = buckets[Math.min(2, Math.floor(p.z * 3))];\n          path.moveTo(p.x, p.y);\n          path.lineTo(p.x - (p.vx / v) * len, p.y - (p.vy / v) * len);\n        } else if (p.kind === \"pellet\") {\n          const r = 0.9 + p.z * 1.1;\n          pellets.moveTo(p.x + r, p.y);\n          pellets.arc(p.x, p.y, r, 0, Math.PI * 2);\n        } else if (flake) {\n          const s = (1.6 + p.z * 4.6) * 2;\n          ctx.globalAlpha = dark ? 0.35 + 0.55 * p.z : 0.3 + 0.5 * p.z;\n          ctx.drawImage(flake, p.x - s / 2, p.y - s / 2, s, s);\n        }\n      }\n      ctx.globalAlpha = 1;\n      const alphas = dark ? [0.16, 0.28, 0.46] : [0.2, 0.34, 0.55];\n      buckets.forEach((path, i) => {\n        ctx.strokeStyle = rgba(ink, alphas[i]);\n        ctx.lineWidth = 0.7 + i * 0.3;\n        ctx.stroke(path);\n      });\n      ctx.fillStyle = rgba(ink, dark ? 0.75 : 0.7);\n      ctx.fill(pellets);\n\n      for (const s of splashes) {\n        if (s.ring) {\n          const r = s.size * (1 - s.life * 0.7);\n          ctx.beginPath();\n          ctx.ellipse(s.x, s.y, r, r * 0.26, 0, 0, Math.PI * 2);\n          ctx.strokeStyle = rgba(ink, 0.4 * s.life);\n          ctx.lineWidth = 0.8;\n          ctx.stroke();\n        } else {\n          ctx.fillStyle = rgba(ink, 0.65 * s.life);\n          ctx.fillRect(s.x - s.size / 2, s.y - s.size / 2, s.size, s.size);\n        }\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      if (ptr.inside && ptr.seen) {\n        // Turn pointer movement since the last frame into a gust velocity (px per 60Hz step).\n        const moved = ptr.x - ptr.lastX;\n        ptr.vx = Math.max(-24, Math.min(24, ptr.vx * 0.5 + (moved / Math.max(1, dt / STEP_MS)) * 0.5));\n      }\n      ptr.lastX = ptr.x;\n      ptr.seen = ptr.inside;\n      // Fixed 60Hz steps: the fall speed is the same on 60, 120 or 144Hz displays. A short frame\n      // may run no step and just redraws the same state; precipitation never settles.\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      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      populate();\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: a finger stirs the air only while pressed.\n    const onPointerDown = (e: PointerEvent) => {\n      if (e.pointerType !== \"touch\") return;\n      ptr.touch = true;\n      ptr.inside = locate(e);\n      ptr.seen = false;\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      readTheme();\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 = gust && !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    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      if (listening) listen(false);\n    };\n  }, [type, intensity, wind, speed, gust, splash, 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-slate-500 dark:text-slate-200\"\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/weather-effect"
}
