{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "snowfall",
  "type": "registry:component",
  "title": "Snowfall",
  "description": "Snow in three layers of depth (far, mid and close bokeh) with a wind that blows toward wherever the cursor is.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Backgrounds"
  ],
  "docs": "Installed in components/furin/snowfall/. Docs, live controls and props table: https://furin.dev/components/snowfall/",
  "files": [
    {
      "path": "registry/snowfall/Snowfall.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./Snowfall.css\";\n\nexport interface SnowfallProps {\n  /** Contenido que va encima de la nieve. */\n  children?: ReactNode;\n  /** Color de los copos. */\n  color?: string;\n  /** Color del fondo de la noche. */\n  background?: string;\n  /** Cuántos copos caen (1 = normal). */\n  density?: number;\n  /** Velocidad de caída (1 = normal). */\n  speed?: number;\n  /** Fuerza del viento que levanta el cursor. */\n  wind?: number;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\nlet colorCtx: CanvasRenderingContext2D | null = null;\nconst RGB_CACHE = new Map<string, [number, number, number]>();\nfunction rgb(css: string, def: [number, number, number]): [number, number, number] {\n  const hit = RGB_CACHE.get(css);\n  if (hit) return hit;\n  try {\n    colorCtx ??= document.createElement(\"canvas\").getContext(\"2d\", { willReadFrequently: true })!;\n    colorCtx.clearRect(0, 0, 1, 1);\n    colorCtx.fillStyle = \"#010203\";\n    colorCtx.fillStyle = css;\n    colorCtx.fillRect(0, 0, 1, 1);\n    const d = colorCtx.getImageData(0, 0, 1, 1).data;\n    const out: [number, number, number] = [d[0], d[1], d[2]];\n    if (RGB_CACHE.size > 64) RGB_CACHE.clear();\n    RGB_CACHE.set(css, out);\n    return out;\n  } catch {\n    return def;\n  }\n}\n\ninterface Copo { x: number; y: number; r: number; vy: number; fase: number; freq: number; amp: number; a: number; }\ninterface Capa { copos: Copo[]; viento: number; velMul: number; sprite: number; }\n\n/** Fondo de nevada: tres planos de profundidad (lejano, medio y un bokeh cercano) y un viento que sopla hacia donde está el cursor. */\nexport function Snowfall({\n  children,\n  color = \"#e6eeff\",\n  background = \"#070a10\",\n  density = 1,\n  speed = 1,\n  wind = 1,\n  className = \"\",\n  style,\n}: SnowfallProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const props = useRef({ color, density, speed, wind });\n  props.current = { color, density, speed, wind };\n  const wakeRef = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const canvas = document.createElement(\"canvas\");\n    canvas.className = \"furin-snowfall__lienzo\";\n    canvas.setAttribute(\"aria-hidden\", \"true\");\n    const ctx = canvas.getContext(\"2d\");\n    if (!ctx) return;\n    root.insertBefore(canvas, root.firstChild);\n\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    let w = 0, h = 0, dpr = 1, raf = 0, visible = true, muerto = false, ult = 0, t = 0;\n    let capas: Capa[] = [];\n    let sprites: HTMLCanvasElement[] = [];\n    let vientoActual = 0;\n    const ptr = { x: 0.5, lx: 0, lt: 0, racha: 0, hay: false };\n    let densClave = -1;\n\n    // Tres sprites: copo nítido, copo suave y disco de bokeh (más claro en el borde, como un lente desenfocado).\n    const armarSprites = () => {\n      const [r, g, b] = rgb(props.current.color, [230, 238, 255]);\n      const mk = (pinta: (c: CanvasRenderingContext2D) => void) => {\n        const cv = document.createElement(\"canvas\"); cv.width = cv.height = 64;\n        pinta(cv.getContext(\"2d\")!); return cv;\n      };\n      sprites = [\n        mk((c) => { const gr = c.createRadialGradient(32, 32, 0, 32, 32, 32); gr.addColorStop(0, `rgba(${r},${g},${b},1)`); gr.addColorStop(0.35, `rgba(${r},${g},${b},0.75)`); gr.addColorStop(0.7, `rgba(${r},${g},${b},0.12)`); gr.addColorStop(1, `rgba(${r},${g},${b},0)`); c.fillStyle = gr; c.fillRect(0, 0, 64, 64); }),\n        mk((c) => { const gr = c.createRadialGradient(32, 32, 0, 32, 32, 32); gr.addColorStop(0, `rgba(${r},${g},${b},0.9)`); gr.addColorStop(0.5, `rgba(${r},${g},${b},0.35)`); gr.addColorStop(1, `rgba(${r},${g},${b},0)`); c.fillStyle = gr; c.fillRect(0, 0, 64, 64); }),\n        mk((c) => { const gr = c.createRadialGradient(32, 32, 0, 32, 32, 31); gr.addColorStop(0, `rgba(${r},${g},${b},0.5)`); gr.addColorStop(0.55, `rgba(${r},${g},${b},0.6)`); gr.addColorStop(0.8, `rgba(${r},${g},${b},0.4)`); gr.addColorStop(1, `rgba(${r},${g},${b},0)`); c.fillStyle = gr; c.fillRect(0, 0, 64, 64); }),\n      ];\n    };\n\n    const nuevo = (c: Copo, tipo: number, anywhere: boolean) => {\n      c.x = Math.random() * w;\n      c.y = anywhere ? Math.random() * h : -10 - Math.random() * 30;\n      c.fase = Math.random() * 6.28;\n      if (tipo === 0) { c.r = 0.9 + Math.random() * 1.1; c.vy = 18 + Math.random() * 14; c.a = 0.35 + Math.random() * 0.35; c.amp = 6; c.freq = 0.5 + Math.random() * 0.8; }\n      else if (tipo === 1) { c.r = 2.2 + Math.random() * 2.4; c.vy = 40 + Math.random() * 28; c.a = 0.5 + Math.random() * 0.4; c.amp = 12; c.freq = 0.6 + Math.random() * 1; }\n      else { c.r = 9 + Math.random() * 12; c.vy = 95 + Math.random() * 60; c.a = 0.09 + Math.random() * 0.13; c.amp = 22; c.freq = 0.4 + Math.random() * 0.6; }\n    };\n\n    const armar = () => {\n      const p = props.current;\n      densClave = p.density;\n      const total = Math.max(40, Math.round(((w * h) / 850) * p.density));\n      const reparto = [0.52, 0.42, 0.06];\n      capas = reparto.map((f, tipo) => {\n        const copos: Copo[] = Array.from({ length: Math.max(3, Math.round(total * f)) }, () => { const c = { x: 0, y: 0, r: 1, vy: 1, fase: 0, freq: 1, amp: 1, a: 1 }; nuevo(c, tipo, true); return c; });\n        return { copos, viento: [0.3, 0.75, 1.5][tipo], velMul: 1, sprite: [1, 0, 2][tipo] };\n      });\n    };\n\n    const pintar = (dt: number) => {\n      const p = props.current;\n      // Viento: una brisa lenta que respira + el empuje del cursor (a la derecha si está a la derecha) + rachas al moverlo.\n      const brisa = Math.sin(t * 0.37) * 10 + Math.sin(t * 0.91 + 1) * 5;\n      const objetivo = brisa + (ptr.hay ? (ptr.x - 0.5) * 2 * 170 * p.wind : 0) + ptr.racha * p.wind;\n      vientoActual += (objetivo - vientoActual) * (1 - Math.exp(-dt * 1.6));\n      ptr.racha *= Math.exp(-dt * 1.8);\n\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, canvas.width, canvas.height);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n\n      for (const capa of capas) {\n        const sp = sprites[capa.sprite];\n        if (!sp) continue;\n        const sw = capa.sprite === 2;\n        for (const c of capa.copos) {\n          const turb = 1 + 0.18 * Math.sin(t * 0.8 + c.fase + c.y * 0.01);\n          c.y += c.vy * p.speed * turb * dt;\n          c.x += (vientoActual * capa.viento + Math.sin(t * c.freq + c.fase) * c.amp) * dt;\n          // La caída se inclina con el viento: copos que se arrastran un poco hacia abajo menos.\n          if (c.y > h + c.r * 2) { nuevo(c, capa.sprite === 1 ? 0 : capa.sprite === 0 ? 1 : 2, false); if (Math.abs(vientoActual) > 40) c.x = vientoActual > 0 ? Math.random() * w - w * 0.2 : Math.random() * w * 1.2; }\n          if (c.x < -c.r * 2) c.x += w + c.r * 4;\n          else if (c.x > w + c.r * 2) c.x -= w + c.r * 4;\n          // Los de bokeh se ven \"respirar\" un poco al enfocar.\n          const rr = sw ? c.r * (1 + 0.06 * Math.sin(t * 0.9 + c.fase)) : c.r;\n          ctx.globalAlpha = c.a * (c.y < 30 ? Math.max(0, c.y / 30) : 1);\n          ctx.drawImage(sp, c.x - rr * 2, c.y - rr * 2, rr * 4, rr * 4);\n        }\n      }\n      ctx.globalAlpha = 1;\n    };\n\n    const paso = (ts: number) => {\n      raf = 0;\n      if (muerto || !visible) return;\n      const dt = Math.min(0.05, ult ? (ts - ult) / 1000 : 0.016);\n      ult = ts;\n      t += dt;\n      pintar(dt);\n      raf = requestAnimationFrame(paso);\n    };\n    const wake = () => { if (reducido) return; if (!raf && visible && !muerto) { ult = 0; raf = requestAnimationFrame(paso); } };\n    wakeRef.current = () => {\n      armarSprites();\n      if (densClave !== props.current.density) armar();\n      if (reducido) pintar(0.016); else wake();\n    };\n\n    const medir = () => {\n      dpr = Math.min(window.devicePixelRatio || 1, 2);\n      const r = root.getBoundingClientRect();\n      w = Math.max(1, r.width); h = Math.max(1, r.height);\n      canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr);\n      armarSprites();\n      armar();\n      if (reducido) pintar(0.016); else { pintar(0.016); wake(); }\n    };\n    const ro = new ResizeObserver(medir);\n    ro.observe(root);\n    const io = new IntersectionObserver(([e]) => { visible = e.isIntersecting; if (visible) wake(); }, { rootMargin: \"80px\" });\n    io.observe(root);\n\n    const onMove = (e: PointerEvent) => {\n      const r = root.getBoundingClientRect();\n      const x = e.clientX - r.left;\n      const ahora = performance.now();\n      if (ptr.hay && ahora - ptr.lt > 0 && ahora - ptr.lt < 120) ptr.racha = Math.max(-260, Math.min(260, ptr.racha + ((x - ptr.lx) / Math.max(1, ahora - ptr.lt)) * 18));\n      ptr.lx = x; ptr.lt = ahora; ptr.x = x / Math.max(1, r.width); ptr.hay = true;\n    };\n    const onLeave = () => { ptr.hay = false; };\n    if (!reducido) {\n      root.addEventListener(\"pointermove\", onMove, { passive: true });\n      root.addEventListener(\"pointerdown\", onMove, { passive: true });\n      root.addEventListener(\"pointerleave\", onLeave);\n    }\n    medir();\n\n    return () => {\n      muerto = true;\n      wakeRef.current = null;\n      cancelAnimationFrame(raf);\n      ro.disconnect(); io.disconnect();\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerdown\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      canvas.remove();\n    };\n  }, []);\n\n  useEffect(() => { wakeRef.current?.(); }, [color, density, speed, wind]);\n\n  const vars = { \"--furin-snowfall-bg\": background, \"--furin-snowfall-bg2\": `color-mix(in srgb, ${background} 82%, #4d6a9c)` } as CSSProperties;\n  return (\n    <div ref={rootRef} className={`furin-snowfall ${className}`} style={{ ...vars, ...style }}>\n      <span className=\"furin-snowfall__bruma\" aria-hidden=\"true\" />\n      {children != null ? <div className=\"furin-snowfall__contenido\">{children}</div> : null}\n    </div>\n  );\n}\n\nexport default Snowfall;\n",
      "type": "registry:component",
      "target": "components/furin/snowfall/Snowfall.tsx"
    },
    {
      "path": "registry/snowfall/Snowfall.css",
      "content": ".furin-snowfall {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  overflow: hidden;\n  isolation: isolate;\n  background: linear-gradient(180deg, var(--furin-snowfall-bg2, #111827) 0%, var(--furin-snowfall-bg, #070a10) 100%);\n  touch-action: pan-y;\n}\n.furin-snowfall__lienzo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; }\n/* Neblina baja y viñeta: dan atmósfera y hacen que la nieve del centro quede más limpia para el texto. */\n.furin-snowfall__bruma {\n  position: absolute;\n  inset: 0;\n  z-index: 1;\n  pointer-events: none;\n  background:\n    linear-gradient(0deg, rgba(190, 210, 240, 0.12), transparent 38%),\n    radial-gradient(ellipse 92% 88% at 50% 45%, transparent 45%, rgba(0, 0, 0, 0.5) 118%);\n}\n.furin-snowfall__contenido { position: relative; z-index: 2; max-width: 100%; pointer-events: none; }\n.furin-snowfall__contenido > * { pointer-events: auto; }\n",
      "type": "registry:file",
      "target": "components/furin/snowfall/Snowfall.css"
    }
  ]
}
