{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "water-ripple",
  "type": "registry:ui",
  "title": "Water Ripple",
  "description": "A refractive water surface: pointer and raindrops send ripples across a tinted, tiled pool.",
  "author": "WANSTUDIO <https://wanstudio.my>",
  "dependencies": [],
  "files": [
    {
      "path": "registry/wancraft/water-ripple.tsx",
      "content": "\"use client\";\n\nimport * as React from \"react\";\n\nexport type WaterRipplePattern = \"tiles\" | \"plain\";\n\nexport interface WaterRippleProps extends React.ComponentPropsWithoutRef<\"div\"> {\n  /** Energy kept per simulation step (0.9–0.995). Higher values let ripples travel further. */\n  damping?: number;\n  /** Simulation cells per CSS pixel (0.1–0.5). The height map is upscaled to the display. */\n  resolution?: number;\n  /** Radius of a pointer drop, in CSS px. */\n  dropRadius?: number;\n  /** How far the surface slope displaces the background, in CSS px. */\n  refraction?: number;\n  /** Let idle raindrops fall on the surface. */\n  rain?: boolean;\n  /** Procedural floor drawn beneath the water when no `src` is given. */\n  pattern?: WaterRipplePattern;\n  /** Water tint. Mixed toward near-black on dark themes and toward paper white on light themes. */\n  color?: string;\n  /** Specular highlight colour. */\n  highlight?: string;\n  /** Optional image to refract instead of the gradient (must be same-origin or CORS-enabled). */\n  src?: string;\n  /** Drop ripples on pointer move, click and tap. */\n  interactive?: 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;\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): RGB => [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t];\nconst css = ([r, g, b]: RGB, alpha = 1) => `rgba(${Math.round(r)}, ${Math.round(g)}, ${Math.round(b)}, ${alpha})`;\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\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 MAX_CELLS = 160_000;\nconst STEP_MS = 1000 / 60;\nconst QUIET_FRAMES = 24;\n\n/**\n * A water surface simulated with the classic two-buffer height map (Hugo Elias) at a reduced\n * resolution. Each frame the slope of the height map refracts a background buffer (a tinted\n * gradient with an optional tile floor, or an image) and adds specular light, then the small\n * frame is upscaled with smoothing onto a DPR-aware canvas. The loop sleeps when the water is\n * calm, offscreen or in a hidden tab.\n */\nexport function WaterRipple({\n  damping = 0.975,\n  resolution = 0.25,\n  dropRadius = 14,\n  refraction = 22,\n  rain = true,\n  pattern = \"tiles\",\n  color = \"#B6F3D1\",\n  highlight = \"#FFFFFF\",\n  src,\n  interactive = true,\n  className = \"\",\n  children,\n  ...props\n}: WaterRippleProps) {\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    // Small canvas holding one simulation frame, and one holding the (unrefracted) background.\n    const sim = document.createElement(\"canvas\");\n    const sctx = sim.getContext(\"2d\");\n    const bgCanvas = document.createElement(\"canvas\");\n    const bctx = bgCanvas.getContext(\"2d\", { willReadFrequently: true });\n    if (!sctx || !bctx) return;\n\n    const keep = Math.min(0.995, Math.max(0.9, damping));\n    const scale = Math.min(0.5, Math.max(0.1, resolution));\n\n    let width = 0;\n    let height = 0;\n    let dpr = 1;\n    let sw = 0;\n    let sh = 0;\n    let cellScale = scale; // cells per CSS px after the MAX_CELLS cap\n    let cur = new Float32Array(0);\n    let prev = new Float32Array(0);\n    let bg = new Uint8ClampedArray(0);\n    let frameImage: ImageData | null = null;\n    let image: HTMLImageElement | null = null;\n    let spec: RGB = [255, 255, 255];\n    let dark = false;\n\n    let raf = 0;\n    let last = 0;\n    let pending = 0;\n    let inView = false;\n    let quiet = 0;\n    let nextRain = 600;\n    const ptr = { x: 0, y: 0, lx: 0, ly: 0, moved: false, has: false };\n\n    /* ------------------------------ Simulation ------------------------------ */\n\n    const step = () => {\n      let peak = 0;\n      for (let y = 1; y < sh - 1; y++) {\n        let i = y * sw + 1;\n        for (let x = 1; x < sw - 1; x++, i++) {\n          const v = ((cur[i - 1] + cur[i + 1] + cur[i - sw] + cur[i + sw]) * 0.5 - prev[i]) * keep;\n          prev[i] = v;\n          const a = v < 0 ? -v : v;\n          if (a > peak) peak = a;\n        }\n      }\n      const t = cur;\n      cur = prev;\n      prev = t;\n      return peak;\n    };\n\n    /** Adds a cosine-shaped dent at a CSS-pixel position. */\n    const drop = (cssX: number, cssY: number, radiusPx: number, strength: number) => {\n      if (!sw) return;\n      const cx = cssX * cellScale;\n      const cy = cssY * cellScale;\n      const r = Math.max(1.5, radiusPx * cellScale);\n      const x0 = Math.max(1, Math.floor(cx - r));\n      const x1 = Math.min(sw - 2, Math.ceil(cx + r));\n      const y0 = Math.max(1, Math.floor(cy - r));\n      const y1 = Math.min(sh - 2, Math.ceil(cy + r));\n      for (let y = y0; y <= y1; y++) {\n        for (let x = x0; x <= x1; x++) {\n          const d = Math.sqrt((x - cx) * (x - cx) + (y - cy) * (y - cy));\n          if (d < r) cur[y * sw + x] -= strength * 0.5 * (1 + Math.cos((Math.PI * d) / r));\n        }\n      }\n      quiet = 0;\n    };\n\n    /* ------------------------------ Background ------------------------------ */\n\n    const buildBackground = () => {\n      if (!sw) return;\n      bgCanvas.width = sw;\n      bgCanvas.height = sh;\n      const ink = resolveColor(getComputedStyle(canvas).color, [24, 24, 27]);\n      dark = luminance(ink) > 0.5;\n      const tint = resolveColor(color, [182, 243, 209]);\n      spec = resolveColor(highlight, [255, 255, 255]);\n      const base: RGB = dark ? [9, 12, 14] : [250, 249, 245];\n      const shallow = mix(tint, base, dark ? 0.72 : 0.62);\n      const deep = mix(tint, base, dark ? 0.86 : 0.5);\n      const deepest = dark ? mix(deep, [0, 0, 0], 0.35) : mix(mix(tint, base, 0.3), [20, 40, 44], 0.25);\n\n      let usedImage = false;\n      if (image) {\n        // Cover-fit the image into the simulation-sized buffer.\n        const ratio = Math.max(sw / image.naturalWidth, sh / image.naturalHeight);\n        const w = image.naturalWidth * ratio;\n        const h = image.naturalHeight * ratio;\n        bctx.drawImage(image, (sw - w) / 2, (sh - h) / 2, w, h);\n        try {\n          bg = bctx.getImageData(0, 0, sw, sh).data;\n          usedImage = true;\n        } catch {\n          image = null; // tainted by a cross-origin image without CORS: fall back to the gradient\n        }\n      }\n      if (!usedImage) {\n        const grad = bctx.createLinearGradient(0, 0, sw * 0.35, sh);\n        grad.addColorStop(0, css(shallow));\n        grad.addColorStop(0.55, css(deep));\n        grad.addColorStop(1, css(deepest));\n        bctx.fillStyle = grad;\n        bctx.fillRect(0, 0, sw, sh);\n        if (pattern === \"tiles\") {\n          const tile = Math.max(6, 44 * cellScale);\n          const line = mix(deepest, dark ? [0, 0, 0] : [10, 30, 34], 0.35);\n          bctx.fillStyle = css(line, dark ? 0.3 : 0.14);\n          const t = Math.max(1, Math.round(1.6 * cellScale * 2) / 2);\n          for (let x = (sw % tile) / 2; x < sw; x += tile) bctx.fillRect(Math.round(x), 0, t, sh);\n          for (let y = (sh % tile) / 2; y < sh; y += tile) bctx.fillRect(0, Math.round(y), sw, t);\n        }\n        // Gentle vignette for depth.\n        const vig = bctx.createRadialGradient(sw / 2, sh * 0.4, Math.min(sw, sh) * 0.2, sw / 2, sh / 2, Math.max(sw, sh) * 0.75);\n        vig.addColorStop(0, \"rgba(0, 0, 0, 0)\");\n        vig.addColorStop(1, dark ? \"rgba(0, 0, 0, 0.35)\" : \"rgba(20, 40, 44, 0.12)\");\n        bctx.fillStyle = vig;\n        bctx.fillRect(0, 0, sw, sh);\n        bg = bctx.getImageData(0, 0, sw, sh).data;\n      }\n    };\n\n    /* -------------------------------- Render -------------------------------- */\n\n    const render = () => {\n      if (!frameImage || bg.length !== sw * sh * 4) return;\n      const out = frameImage.data;\n      const refr = refraction * cellScale;\n      const shine = dark ? 0.9 : 0.75;\n      const shadow = dark ? 0.45 : 0.3;\n      const [hr, hg, hb] = spec;\n      for (let y = 0; y < sh; y++) {\n        for (let x = 0; x < sw; x++) {\n          const i = y * sw + x;\n          const o = i * 4;\n          if (x === 0 || y === 0 || x === sw - 1 || y === sh - 1) {\n            out[o] = bg[o];\n            out[o + 1] = bg[o + 1];\n            out[o + 2] = bg[o + 2];\n            out[o + 3] = 255;\n            continue;\n          }\n          const dx = cur[i - 1] - cur[i + 1];\n          const dy = cur[i - sw] - cur[i + sw];\n          // Bilinear sample of the background at the refracted position.\n          let fx = x + dx * refr;\n          let fy = y + dy * refr;\n          fx = fx < 0 ? 0 : fx > sw - 1.001 ? sw - 1.001 : fx;\n          fy = fy < 0 ? 0 : fy > sh - 1.001 ? sh - 1.001 : fy;\n          const ix = fx | 0;\n          const iy = fy | 0;\n          const tx = fx - ix;\n          const ty = fy - iy;\n          const b00 = (iy * sw + ix) * 4;\n          const b10 = b00 + 4;\n          const b01 = b00 + sw * 4;\n          const b11 = b01 + 4;\n          const w00 = (1 - tx) * (1 - ty);\n          const w10 = tx * (1 - ty);\n          const w01 = (1 - tx) * ty;\n          const w11 = tx * ty;\n          let r = bg[b00] * w00 + bg[b10] * w10 + bg[b01] * w01 + bg[b11] * w11;\n          let g = bg[b00 + 1] * w00 + bg[b10 + 1] * w10 + bg[b01 + 1] * w01 + bg[b11 + 1] * w11;\n          let bl = bg[b00 + 2] * w00 + bg[b10 + 2] * w10 + bg[b01 + 2] * w01 + bg[b11 + 2] * w11;\n          // Light from the top-left: slopes facing it brighten toward the highlight, others darken.\n          const light = (dx + dy) * shine;\n          if (light > 0) {\n            const k = light > 1 ? 1 : light * light * (3 - 2 * light);\n            r += (hr - r) * k;\n            g += (hg - g) * k;\n            bl += (hb - bl) * k;\n          } else if (light < 0) {\n            const k = 1 - (light < -1 ? 1 : -light) * shadow;\n            r *= k;\n            g *= k;\n            bl *= k;\n          }\n          out[o] = r;\n          out[o + 1] = g;\n          out[o + 2] = bl;\n          out[o + 3] = 255;\n        }\n      }\n      sctx.putImageData(frameImage, 0, 0);\n      ctx.setTransform(1, 0, 0, 1, 0, 0);\n      ctx.imageSmoothingEnabled = true;\n      ctx.imageSmoothingQuality = \"high\";\n      ctx.drawImage(sim, 0, 0, canvas.width, canvas.height);\n    };\n\n    /* --------------------------------- Loop --------------------------------- */\n\n    const frame = (now: number) => {\n      raf = 0;\n      const dt = Math.min(64, last ? now - last : STEP_MS);\n      last = now;\n\n      if (interactive && ptr.moved) {\n        // Dents along the pointer path, scaled by how far it travelled this frame.\n        const dist = Math.hypot(ptr.x - ptr.lx, ptr.y - ptr.ly);\n        const n = Math.min(4, Math.ceil(dist / (dropRadius * 0.8)));\n        const strength = Math.min(0.5, 0.05 + dist / 120);\n        for (let k = 1; k <= n; k++) {\n          const t = k / n;\n          drop(ptr.lx + (ptr.x - ptr.lx) * t, ptr.ly + (ptr.y - ptr.ly) * t, dropRadius * 0.6, strength / n);\n        }\n        ptr.lx = ptr.x;\n        ptr.ly = ptr.y;\n        ptr.moved = false;\n      }\n\n      if (rain) {\n        nextRain -= dt;\n        if (nextRain <= 0) {\n          drop(Math.random() * width, Math.random() * height, dropRadius * (0.4 + Math.random() * 0.5), 0.45 + Math.random() * 0.5);\n          nextRain = 350 + Math.random() * 1100;\n        }\n      }\n\n      // Fixed 60Hz steps keep the wave speed identical on 60, 120 and 144Hz displays.\n      pending = Math.min(pending + dt, STEP_MS * 3);\n      let peak = 0;\n      let stepped = false;\n      while (pending >= STEP_MS) {\n        peak = Math.max(peak, step());\n        pending -= STEP_MS;\n        stepped = true;\n      }\n      if (stepped) {\n        render();\n        quiet = peak < 0.002 ? quiet + 1 : 0;\n      }\n      schedule();\n    };\n\n    function schedule() {\n      const calm = !rain && quiet > QUIET_FRAMES;\n      if (!raf && inView && !reduceMotion && !document.hidden && !calm) raf = requestAnimationFrame(frame);\n    }\n    const stop = () => {\n      cancelAnimationFrame(raf);\n      raf = 0;\n      last = 0;\n    };\n\n    /** Reduced motion: a still surface with a few frozen rings for texture. */\n    const drawStill = () => {\n      drop(width * 0.3, height * 0.4, dropRadius * 1.4, 1.4);\n      drop(width * 0.72, height * 0.62, dropRadius * 1.2, 1.2);\n      for (let n = 0; n < 46; n++) step();\n      render();\n    };\n\n    const resize = () => {\n      const rect = canvas.getBoundingClientRect();\n      if (Math.abs(rect.width - width) < 1 && Math.abs(rect.height - height) < 1 && sw > 0) return;\n      width = Math.max(1, rect.width);\n      height = Math.max(1, 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      cellScale = scale;\n      if (width * height * cellScale * cellScale > MAX_CELLS) cellScale = Math.sqrt(MAX_CELLS / (width * height));\n      sw = Math.max(3, Math.ceil(width * cellScale));\n      sh = Math.max(3, Math.ceil(height * cellScale));\n      sim.width = sw;\n      sim.height = sh;\n      cur = new Float32Array(sw * sh);\n      prev = new Float32Array(sw * sh);\n      frameImage = sctx.createImageData(sw, sh);\n      buildBackground();\n      if (reduceMotion) drawStill();\n      else render();\n      schedule();\n    };\n\n    const wake = () => {\n      quiet = 0;\n      schedule();\n    };\n\n    /* -------------------------------- Input --------------------------------- */\n\n    const local = (e: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      return [e.clientX - rect.left, e.clientY - rect.top] as const;\n    };\n    const onPointerMove = (e: PointerEvent) => {\n      if (e.pointerType !== \"mouse\" && e.pointerType !== \"pen\") return;\n      const [x, y] = local(e);\n      if (!ptr.has) {\n        ptr.lx = x;\n        ptr.ly = y;\n        ptr.has = true;\n      }\n      ptr.x = x;\n      ptr.y = y;\n      ptr.moved = true;\n      wake();\n    };\n    const onPointerLeave = () => {\n      ptr.has = false;\n      ptr.moved = false;\n    };\n    const onPointerDown = (e: PointerEvent) => {\n      const [x, y] = local(e);\n      drop(x, y, dropRadius, 1.6);\n      wake();\n    };\n\n    const onThemeChange = () => {\n      buildBackground();\n      if (!raf) render();\n    };\n    const onVisibility = () => (document.hidden ? stop() : schedule());\n\n    const io = new IntersectionObserver(([entry]) => {\n      inView = entry.isIntersecting;\n      if (inView) schedule();\n      else stop();\n    });\n\n    if (src) {\n      const img = new Image();\n      img.crossOrigin = \"anonymous\";\n      img.decoding = \"async\";\n      img.onload = () => {\n        image = img;\n        buildBackground();\n        if (reduceMotion) drawStill();\n        else if (!raf) render();\n      };\n      img.src = src;\n    }\n\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    const listen = interactive && !reduceMotion;\n    if (listen) {\n      root.addEventListener(\"pointermove\", onPointerMove, { passive: true });\n      root.addEventListener(\"pointerleave\", onPointerLeave);\n      root.addEventListener(\"pointerdown\", onPointerDown, { passive: true });\n    }\n\n    return () => {\n      stop();\n      ro.disconnect();\n      io.disconnect();\n      unobserveTheme();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      root.removeEventListener(\"pointermove\", onPointerMove);\n      root.removeEventListener(\"pointerleave\", onPointerLeave);\n      root.removeEventListener(\"pointerdown\", onPointerDown);\n    };\n  }, [damping, resolution, dropRadius, refraction, rain, pattern, color, highlight, src, interactive, reduceMotion]);\n\n  return (\n    <div\n      ref={rootRef}\n      className={`absolute inset-0 overflow-hidden bg-[#E9F3EE] dark:bg-[#0B1113] ${className}`}\n      {...props}\n    >\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/water-ripple"
}
