{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "code-rain",
  "type": "registry:component",
  "title": "Code Rain",
  "description": "Columns of real JSX and TypeScript snippets falling across three depth planes, with a glowing head and syntax highlighting.",
  "author": "Marcelo Treviño Velázquez <https://furin.dev>",
  "dependencies": [],
  "categories": [
    "Backgrounds"
  ],
  "docs": "Installed in components/furin/code-rain/. Docs, live controls and props table: https://furin.dev/components/code-rain/",
  "files": [
    {
      "path": "registry/code-rain/CodeRain.tsx",
      "content": "\"use client\";\nimport { useEffect, useRef } from \"react\";\nimport type { CSSProperties, ReactNode } from \"react\";\nimport \"./CodeRain.css\";\n\nexport interface CodeRainProps {\n  /** Contenido que va encima de la lluvia. */\n  children?: ReactNode;\n  /** Color del código (el cuerpo de cada columna). */\n  color?: string;\n  /** Color de la punta que va cayendo. */\n  headColor?: string;\n  /** Color del fondo. */\n  background?: string;\n  /** Velocidad de caída (1 = normal). */\n  speed?: number;\n  /** Cuántas columnas caen a la vez (1 = normal). */\n  density?: number;\n  /** Tamaño de la letra del plano principal, en px. */\n  fontSize?: number;\n  /** Tiñe palabras clave, cadenas y signos como un editor de código. */\n  syntax?: boolean;\n  /** Clase extra para el contenedor. */\n  className?: string;\n  /** Estilos extra para el contenedor. */\n  style?: CSSProperties;\n}\n\n// Fragmentos de código real (JSX/TS) que bajan, un carácter por fila.\nconst SNIPPETS = [\n  \"const [open, setOpen] = useState(false);\",\n  \"export default function Hero() {\",\n  \"useEffect(() => { ref.current?.focus(); }, []);\",\n  \"<div className=\\\"grid place-items-center\\\">\",\n  \"return <Button onClick={toggle}>Ship it</Button>;\",\n  \"import { motion } from \\\"framer-motion\\\";\",\n  \"interface Props { title: string; items: Item[] }\",\n  \"const res = await fetch(\\\"/api/user\\\", { cache: \\\"no-store\\\" });\",\n  \"items.map((item) => <Card key={item.id} {...item} />)\",\n  \"type State = \\\"idle\\\" | \\\"loading\\\" | \\\"error\\\";\",\n  \"export const metadata: Metadata = { title: \\\"Furin\\\" };\",\n  \"if (!user) return redirect(\\\"/login\\\");\",\n  \"const ctx = canvas.getContext(\\\"2d\\\")!;\",\n  \"requestAnimationFrame(loop);\",\n  \"await Promise.all(tasks.map(run));\",\n  \"const total = useMemo(() => sum(rows), [rows]);\",\n  \"</section>\",\n  \"npm run build && npm run start\",\n  \"git commit -m \\\"feat: add hero\\\"\",\n  \"export function cn(...c: string[]) { return c.join(\\\" \\\"); }\",\n  \"const router = useRouter();\",\n  \"async function load(id: number): Promise<Post> {\",\n  \"<Suspense fallback={<Spinner />}>\",\n  \"setCount((n) => n + 1);\",\n  \"throw new Error(\\\"not implemented\\\");\",\n  \"export type { ComponentMeta } from \\\"./types\\\";\",\n  \"ref.current.style.transform = `translateY(${y}px)`;\",\n  \"const observer = new IntersectionObserver(cb);\",\n];\n\nconst KEYWORDS = new Set([\"const\", \"let\", \"return\", \"import\", \"export\", \"from\", \"default\", \"function\", \"async\", \"await\", \"if\", \"type\", \"interface\", \"new\", \"throw\", \"typeof\"]);\n// Clases de color: 0 cuerpo, 1 palabra clave, 2 cadena, 3 signo.\nfunction clasificar(s: string): Uint8Array {\n  const cls = new Uint8Array(s.length);\n  let i = 0;\n  while (i < s.length) {\n    const c = s[i];\n    if (c === '\"' || c === \"`\") {\n      let j = i + 1;\n      while (j < s.length && s[j] !== c) j++;\n      for (let k = i; k <= Math.min(j, s.length - 1); k++) cls[k] = 2;\n      i = j + 1;\n    } else if (/[A-Za-z_]/.test(c)) {\n      let j = i;\n      while (j < s.length && /[A-Za-z0-9_]/.test(s[j])) j++;\n      if (KEYWORDS.has(s.slice(i, j))) for (let k = i; k < j; k++) cls[k] = 1;\n      i = j;\n    } else {\n      if (/[{}()[\\]<>;,=:.|&!?/+\\-*$]/.test(c)) cls[i] = 3;\n      i++;\n    }\n  }\n  return cls;\n}\nconst SNIP_CLS = SNIPPETS.map(clasificar);\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 Columna { x: number; snip: number; ini: number; cabeza: number; vel: number; cola: number; espera: number; }\ninterface Capa {\n  cv: HTMLCanvasElement; ctx: CanvasRenderingContext2D;\n  tam: number; velMul: number; alfaMul: number; densMul: number; colW: number; lineH: number;\n  cols: Columna[];\n}\n\nconst NIV = 9;\nconst FUENTE = `\"JetBrains Mono\", ui-monospace, \"SF Mono\", Menlo, Consolas, monospace`;\n\n/** Fondo de lluvia de código: columnas de fragmentos reales de JSX/TS que caen en tres planos de profundidad, con la punta brillante. */\nexport function CodeRain({\n  children,\n  color = \"#8fb0c4\",\n  headColor = \"#eaf6ff\",\n  background = \"#07090c\",\n  speed = 1,\n  density = 1,\n  fontSize = 14,\n  syntax = true,\n  className = \"\",\n  style,\n}: CodeRainProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const props = useRef({ color, headColor, speed, density, fontSize, syntax });\n  props.current = { color, headColor, speed, density, fontSize, syntax };\n  const wakeRef = useRef<(() => void) | null>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    if (!root) return;\n    const reducido = matchMedia(\"(prefers-reduced-motion: reduce)\").matches;\n    const mk = (mod: string) => {\n      const cv = document.createElement(\"canvas\");\n      cv.className = `furin-code-rain__lienzo ${mod}`;\n      cv.setAttribute(\"aria-hidden\", \"true\");\n      return cv;\n    };\n    const cvLejos = mk(\"furin-code-rain__lienzo--lejos\"), cvMedio = mk(\"\"), cvCerca = mk(\"furin-code-rain__lienzo--cerca\");\n    const c0 = cvLejos.getContext(\"2d\"), c1 = cvMedio.getContext(\"2d\"), c2 = cvCerca.getContext(\"2d\");\n    if (!c0 || !c1 || !c2) return;\n    root.insertBefore(cvCerca, root.firstChild);\n    root.insertBefore(cvMedio, root.firstChild);\n    root.insertBefore(cvLejos, root.firstChild);\n\n    let w = 0, h = 0, dpr = 1, raf = 0, visible = true, muerto = false, ult = 0;\n    const ptr = { x: -999, y: -999, k: 0, kObj: 0 };\n    let capas: Capa[] = [];\n    let sprite: HTMLCanvasElement | null = null;\n    let spriteClave = \"\";\n\n    const nuevaCapa = (cv: HTMLCanvasElement, ctx: CanvasRenderingContext2D, k: number, velMul: number, alfaMul: number, densMul: number): Capa => ({ cv, ctx, tam: 0, velMul, alfaMul, densMul, colW: 0, lineH: 0, cols: [], ...{ _k: k } as object });\n\n    const reciclar = (c: Columna, cap: Capa, inicial: boolean) => {\n      c.snip = Math.floor(Math.random() * SNIPPETS.length);\n      const largo = SNIPPETS[c.snip].length;\n      const filas = h / cap.lineH;\n      c.cola = 12 + Math.random() * 16;\n      c.vel = (5 + Math.random() * 7) * cap.velMul;\n      // El texto empieza en una fila aleatoria superior; al inicio las columnas ya están repartidas por la pantalla.\n      c.ini = -Math.floor(Math.random() * Math.min(8, filas * 0.3));\n      c.cabeza = inicial ? c.ini + Math.random() * (largo + c.cola) : c.ini - 1;\n      c.espera = inicial ? 0 : Math.random() * 4;\n    };\n\n    const armar = () => {\n      const p = props.current;\n      const tamBase = Math.max(8, p.fontSize);\n      const defs: [HTMLCanvasElement, CanvasRenderingContext2D, number, number, number, number][] = [\n        [cvLejos, c0, tamBase * 0.66, 0.6, 0.55, 1],\n        [cvMedio, c1, tamBase, 1, 1, 0.85],\n        [cvCerca, c2, tamBase * 2.3, 1.7, 0.6, 0.16],\n      ];\n      capas = defs.map(([cv, ctx, tam, velMul, alfaMul, dens]) => {\n        const cap = nuevaCapa(cv, ctx, 0, velMul, alfaMul, dens);\n        cv.width = Math.round(w * dpr); cv.height = Math.round(h * dpr);\n        cap.tam = tam;\n        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n        ctx.font = `500 ${tam}px ${FUENTE}`;\n        cap.colW = Math.max(4, ctx.measureText(\"M\").width * 1.12);\n        cap.lineH = tam * 1.32;\n        const n = Math.floor(w / cap.colW);\n        for (let i = 0; i < n; i++) {\n          if (Math.random() > Math.min(1, cap.densMul * p.density)) continue;\n          const col: Columna = { x: i * cap.colW + cap.colW / 2, snip: 0, ini: 0, cabeza: 0, vel: 0, cola: 0, espera: 0 };\n          reciclar(col, cap, true);\n          cap.cols.push(col);\n        }\n        return cap;\n      });\n    };\n\n    const armarSprite = () => {\n      const [r, g, b] = rgb(props.current.color, [143, 176, 196]);\n      const clave = `${r},${g},${b}`;\n      if (sprite && spriteClave === clave) return;\n      spriteClave = clave;\n      sprite = document.createElement(\"canvas\");\n      sprite.width = sprite.height = 64;\n      const sc = sprite.getContext(\"2d\")!;\n      const gr = sc.createRadialGradient(32, 32, 0, 32, 32, 32);\n      gr.addColorStop(0, \"rgba(255,255,255,0.4)\");\n      gr.addColorStop(0.3, `rgba(${r},${g},${b},0.16)`);\n      gr.addColorStop(1, `rgba(${r},${g},${b},0)`);\n      sc.fillStyle = gr; sc.fillRect(0, 0, 64, 64);\n    };\n\n    const pintarCapa = (cap: Capa, dt: number) => {\n      const p = props.current;\n      const ctx = cap.ctx;\n      const base = rgb(p.color, [143, 176, 196]);\n      const cabC = rgb(p.headColor, [234, 246, 255]);\n      // Paleta por clase: cuerpo, palabra clave, cadena, signo.\n      const paleta: [number, number, number][] = p.syntax\n        ? [base, [base[0] * 0.55 + 190 * 0.45, base[1] * 0.55 + 150 * 0.45, base[2] * 0.55 + 235 * 0.45], [base[0] * 0.5 + 232 * 0.5, base[1] * 0.5 + 190 * 0.5, base[2] * 0.5 + 130 * 0.5], [base[0] * 0.55, base[1] * 0.55, base[2] * 0.6]]\n        : [base, base, base, [base[0] * 0.7, base[1] * 0.7, base[2] * 0.7]];\n\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.clearRect(0, 0, cap.cv.width, cap.cv.height);\n      ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n      ctx.font = `500 ${cap.tam}px ${FUENTE}`;\n      ctx.textAlign = \"center\";\n      ctx.textBaseline = \"middle\";\n\n      const buckets: { x: number; y: number; ch: string }[][] = Array.from({ length: 4 * NIV }, () => []);\n      const heads: { x: number; y: number; a: number; ch: string }[] = [];\n      const filas = h / cap.lineH;\n\n      for (const col of cap.cols) {\n        if (col.espera > 0) { col.espera -= dt; continue; }\n        // El cursor acelera y enciende las columnas que toca.\n        const cerca = ptr.k > 0.02 ? Math.max(0, 1 - Math.abs(col.x - ptr.x) / 110) * ptr.k : 0;\n        col.cabeza += col.vel * p.speed * (1 + cerca * 0.9) * dt;\n        const txt = SNIPPETS[col.snip], cls = SNIP_CLS[col.snip];\n        const fin = col.ini + txt.length;\n        if (col.cabeza - col.cola > Math.max(fin, filas + 1)) { reciclar(col, cap, false); continue; }\n        const hi = Math.min(Math.floor(col.cabeza), fin - 1);\n        const lo = Math.max(Math.ceil(col.cabeza - col.cola), col.ini, Math.floor(-1));\n        for (let r = hi; r >= lo; r--) {\n          const y = r * cap.lineH + cap.lineH / 2;\n          if (y < -cap.lineH || y > h + cap.lineH) continue;\n          const dist = col.cabeza - r;\n          let a = Math.pow(Math.max(0, 1 - dist / col.cola), 1.25);\n          a = a * (0.2 + 0.8 * cap.alfaMul) + cerca * 0.35 * a;\n          if (a < 0.03) continue;\n          const nivel = Math.min(NIV - 1, Math.floor(Math.min(1, a) * NIV));\n          const ch = txt[r - col.ini];\n          if (ch === \" \") continue;\n          buckets[cls[r - col.ini] * NIV + nivel].push({ x: col.x, y, ch });\n        }\n        // La punta: carácter blanco con su halo.\n        const rh = Math.floor(col.cabeza);\n        if (rh >= col.ini && rh < fin) {\n          const y = rh * cap.lineH + cap.lineH / 2;\n          if (y > -cap.lineH && y < h + cap.lineH) heads.push({ x: col.x, y, a: 0.45 + 0.55 * cap.alfaMul, ch: txt[rh - col.ini] });\n        }\n      }\n\n      for (let cl = 0; cl < 4; cl++) {\n        const [r, g, b] = paleta[cl];\n        for (let n = 0; n < NIV; n++) {\n          const arr = buckets[cl * NIV + n];\n          if (!arr.length) continue;\n          const a = ((n + 0.5) / NIV) * (cl === 3 ? 0.8 : 1);\n          ctx.fillStyle = `rgba(${r | 0},${g | 0},${b | 0},${a.toFixed(3)})`;\n          for (const q of arr) ctx.fillText(q.ch, q.x, q.y);\n        }\n      }\n      if (heads.length && sprite) {\n        ctx.globalCompositeOperation = \"lighter\";\n        const sz = cap.tam * 2.4;\n        for (const hd of heads) { ctx.globalAlpha = hd.a; ctx.drawImage(sprite, hd.x - sz / 2, hd.y - sz / 2, sz, sz); }\n        ctx.globalAlpha = 1;\n        ctx.globalCompositeOperation = \"source-over\";\n        ctx.fillStyle = `rgba(${cabC[0]},${cabC[1]},${cabC[2]},0.95)`;\n        for (const hd of heads) {\n          if (hd.ch && hd.ch !== \" \") { ctx.globalAlpha = Math.min(1, hd.a + 0.15); ctx.fillText(hd.ch, hd.x, hd.y); }\n        }\n        ctx.globalAlpha = 1;\n      }\n    };\n\n    const pintar = (dt: number) => {\n      for (const cap of capas) pintarCapa(cap, dt);\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      ptr.k += (ptr.kObj - ptr.k) * (1 - Math.exp(-dt * 6));\n      pintar(dt);\n      raf = requestAnimationFrame(paso);\n    };\n    const wake = () => { if (reducido) return; if (!raf && visible && !muerto) { ult = 0; raf = requestAnimationFrame(paso); } };\n\n    let claveGeom = \"\";\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      claveGeom = `${props.current.fontSize}|${props.current.density}`;\n      armarSprite();\n      armar();\n      if (reducido) { pintar(0.016); } else wake();\n    };\n    wakeRef.current = () => {\n      armarSprite();\n      if (claveGeom !== `${props.current.fontSize}|${props.current.density}`) { claveGeom = `${props.current.fontSize}|${props.current.density}`; armar(); }\n      if (reducido) pintar(0.016); else 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      ptr.x = e.clientX - r.left; ptr.y = e.clientY - r.top; ptr.kObj = 1;\n    };\n    const onLeave = () => { ptr.kObj = 0; };\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      cvLejos.remove(); cvMedio.remove(); cvCerca.remove();\n    };\n  }, []);\n\n  useEffect(() => { wakeRef.current?.(); }, [color, headColor, speed, density, fontSize, syntax]);\n\n  const vars = { \"--furin-code-rain-bg\": background } as CSSProperties;\n  return (\n    <div ref={rootRef} className={`furin-code-rain ${className}`} style={{ ...vars, ...style }}>\n      <span className=\"furin-code-rain__vineta\" aria-hidden=\"true\" />\n      {children != null ? <div className=\"furin-code-rain__contenido\">{children}</div> : null}\n    </div>\n  );\n}\n\nexport default CodeRain;\n",
      "type": "registry:component",
      "target": "components/furin/code-rain/CodeRain.tsx"
    },
    {
      "path": "registry/code-rain/CodeRain.css",
      "content": ".furin-code-rain {\n  position: absolute;\n  inset: 0;\n  display: grid;\n  place-items: center;\n  overflow: hidden;\n  isolation: isolate;\n  background: var(--furin-code-rain-bg, #07090c);\n  touch-action: pan-y;\n}\n.furin-code-rain__lienzo { position: absolute; inset: 0; width: 100%; height: 100%; display: block; z-index: 0; }\n/* Profundidad de campo: el plano lejano se suaviza un poco y el cercano es un bokeh de letras grandes. */\n.furin-code-rain__lienzo--lejos { filter: blur(0.7px); }\n.furin-code-rain__lienzo--cerca { filter: blur(2.4px); }\n.furin-code-rain__vineta {\n  position: absolute;\n  inset: 0;\n  z-index: 1;\n  pointer-events: none;\n  background:\n    radial-gradient(ellipse 60% 48% at 50% 50%, rgba(0, 0, 0, 0.45), transparent 100%),\n    radial-gradient(ellipse 92% 88% at 50% 50%, transparent 40%, var(--furin-code-rain-bg, #07090c) 115%);\n}\n.furin-code-rain__contenido { position: relative; z-index: 2; max-width: 100%; pointer-events: none; }\n.furin-code-rain__contenido > * { pointer-events: auto; }\n",
      "type": "registry:file",
      "target": "components/furin/code-rain/CodeRain.css"
    }
  ]
}
