{
  "name": "caustics-background",
  "type": "registry:component",
  "dependencies": [],
  "files": [
    {
      "type": "registry:component",
      "content": "\"use client\";\n\n/**\n * @author: @dorianbaffier\n * @description: Caustics Background\n * @version: 1.0.0\n * @date: 2026-10-22\n * @license: MIT\n * @website: https://kokonutui.com\n * @github: https://github.com/kokonut-labs/kokonutui\n */\n\nimport { type ReactNode, useEffect, useRef } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CausticsBackgroundProps {\n  /** The headline. It floats on the water and casts a shadow on the floor. */\n  title?: string;\n  /**\n   * Content under the headline. Give an element `data-shadow=\"box\"` and it\n   * casts a shadow too.\n   */\n  children?: ReactNode;\n  /** Set false for water you can't touch. */\n  interactive?: boolean;\n  className?: string;\n}\n\n// Ripples alive at once. A new one replaces the oldest.\nconst RIPPLES = 12;\n// How far apart the caustic cells are, in px.\nconst CELL = 150;\n// Where the floor shadow lands, from the thing that casts it, in px.\nconst SHADOW_X = 16;\nconst SHADOW_Y = 30;\nconst SHADOW_BLUR = 7;\n// Moving the pointer faster than this (px/ms) leaves a wake.\nconst WAKE_SPEED = 0.25;\nconst WAKE_EVERY = 70;\n\nconst VERTEX = `#version 300 es\nin vec2 aPos;\nout vec2 vUv;\nvoid main() {\n  vUv = aPos * 0.5 + 0.5;\n  gl_Position = vec4(aPos, 0.0, 1.0);\n}`;\n\nconst FRAGMENT = `#version 300 es\nprecision highp float;\nin vec2 vUv;\nout vec4 o;\nuniform vec2 uRes;\nuniform float uTime;\nuniform vec4 uRipples[${RIPPLES}];\nuniform sampler2D uMask;\nuniform vec3 uWater;\nuniform vec3 uLight;\nuniform vec2 uShadow;\n\nconst float TAU = 6.28318530718;\n\nvec2 hash(vec2 p) {\n  p = vec2(dot(p, vec2(127.1, 311.7)), dot(p, vec2(269.5, 183.3)));\n  return fract(sin(p) * 43758.5453);\n}\n\n// The bright web of light on a pool floor: the edges between drifting\n// cells, where the waves focus the sun.\nfloat caustic(vec2 p, float t) {\n  vec2 q = p;\n  p += 0.34 * vec2(sin(p.y * 1.3 + t * 0.9), cos(p.x * 1.1 - t * 0.7));\n  p += 0.12 * vec2(sin(p.y * 3.1 - t), cos(p.x * 2.7 + t * 1.2));\n  vec2 n = floor(p);\n  vec2 f = fract(p);\n  float f1 = 8.0;\n  float f2 = 8.0;\n  for (int j = -1; j <= 1; j++) {\n    for (int i = -1; i <= 1; i++) {\n      vec2 g = vec2(float(i), float(j));\n      vec2 o = 0.5 + 0.42 * sin(t + TAU * hash(n + g));\n      vec2 r = g + o - f;\n      float d = dot(r, r);\n      if (d < f1) {\n        f2 = f1;\n        f1 = d;\n      } else if (d < f2) {\n        f2 = d;\n      }\n    }\n  }\n  float edge = sqrt(f2) - sqrt(f1);\n  // A sharp core and a soft halo, brighter in some patches than others.\n  float line = pow(1.0 - smoothstep(0.0, 0.14, edge), 2.0);\n  float halo = 1.0 - smoothstep(0.0, 0.32, edge);\n  float glint = 0.6 + 0.4 * sin(q.x * 0.55 + t * 0.6) * cos(q.y * 0.45 - t * 0.5);\n  return (line * 0.85 + halo * 0.18) * glint;\n}\n\nvoid main() {\n  vec2 px = vec2(vUv.x, 1.0 - vUv.y) * uRes;\n  vec2 bend = vec2(0.0);\n  float focus = 0.0;\n  for (int k = 0; k < ${RIPPLES}; k++) {\n    vec4 r = uRipples[k];\n    float age = uTime - r.z;\n    if (r.w <= 0.0 || age < 0.0 || age > 4.0) {\n      continue;\n    }\n    vec2 d = px - r.xy;\n    float dist = length(d);\n    float x = dist - age * 240.0;\n    float env = r.w * exp(-age * 0.8) * exp(-x * x / 2600.0) / (1.0 + dist * 0.002);\n    bend += (d / max(dist, 1.0)) * sin(x * 0.085) * env * 26.0;\n    focus += cos(x * 0.085) * env;\n  }\n  vec2 uv = (px + bend) / ${CELL}.0;\n  float t = uTime * 0.5;\n  float c = caustic(uv, t);\n  // A fainter, finer web drifting under the first gives the floor depth.\n  c = max(c, caustic(uv * 1.9 + 7.3, t * 0.8 + 3.0) * 0.22);\n  // A ring's crest focuses light like a lens and its trough spreads it, so\n  // each ring shows as a bright band with a dim one beside it.\n  c = clamp(c * (1.0 + focus * 1.2) + focus * 0.4, 0.0, 1.0);\n\n  // Whatever floats on the surface blocks the light under it. The water\n  // bends the shadow the same way it bends the light.\n  vec2 sway = vec2(sin(uTime * 0.7), cos(uTime * 0.55)) * 2.5;\n  vec2 s = (px - uShadow - bend * 1.4 - sway) / uRes;\n  float shade = texture(uMask, s).r;\n\n  vec3 floor = uWater * (1.0 - shade * 0.08);\n  vec3 col = mix(floor, uLight, c * (1.0 - shade * 0.9));\n  o = vec4(col, 1.0);\n}`;\n\nfunction compile(gl: WebGL2RenderingContext, type: number, source: string) {\n  const shader = gl.createShader(type);\n  if (!shader) {\n    return null;\n  }\n  gl.shaderSource(shader, source);\n  gl.compileShader(shader);\n  if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {\n    gl.deleteShader(shader);\n    return null;\n  }\n  return shader;\n}\n\n/** Parses any CSS color, oklch included, by painting it into one pixel. */\nfunction toRgb(color: string, probe: CanvasRenderingContext2D) {\n  probe.clearRect(0, 0, 1, 1);\n  probe.fillStyle = color;\n  probe.fillRect(0, 0, 1, 1);\n  const [r, g, b] = probe.getImageData(0, 0, 1, 1).data;\n  return [r / 255, g / 255, b / 255];\n}\n\ninterface Water {\n  resize: (w: number, h: number, cssW: number, cssH: number) => void;\n  setMask: (source: HTMLCanvasElement) => void;\n  setColors: (water: number[], light: number[]) => void;\n  setRipples: (data: Float32Array) => void;\n  draw: (time: number) => void;\n  destroy: () => void;\n}\n\nfunction createWater(gl: WebGL2RenderingContext): Water | null {\n  const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);\n  const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);\n  const program = gl.createProgram();\n  if (!(vertex && fragment && program)) {\n    return null;\n  }\n  gl.attachShader(program, vertex);\n  gl.attachShader(program, fragment);\n  gl.bindAttribLocation(program, 0, \"aPos\");\n  gl.linkProgram(program);\n  if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {\n    return null;\n  }\n  const at = (name: string) => gl.getUniformLocation(program, name);\n  const u = {\n    res: at(\"uRes\"),\n    time: at(\"uTime\"),\n    ripples: at(\"uRipples\"),\n    mask: at(\"uMask\"),\n    water: at(\"uWater\"),\n    light: at(\"uLight\"),\n    shadow: at(\"uShadow\"),\n  };\n\n  const quad = gl.createBuffer();\n  gl.bindBuffer(gl.ARRAY_BUFFER, quad);\n  gl.bufferData(\n    gl.ARRAY_BUFFER,\n    new Float32Array([-1, -1, 1, -1, -1, 1, 1, 1]),\n    gl.STATIC_DRAW\n  );\n  gl.enableVertexAttribArray(0);\n  gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n\n  const mask = gl.createTexture();\n  gl.bindTexture(gl.TEXTURE_2D, mask);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);\n  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n  gl.texImage2D(\n    gl.TEXTURE_2D,\n    0,\n    gl.R8,\n    1,\n    1,\n    0,\n    gl.RED,\n    gl.UNSIGNED_BYTE,\n    new Uint8Array([0])\n  );\n\n  // biome-ignore lint/correctness/useHookAtTopLevel: WebGL's useProgram, not a React hook.\n  gl.useProgram(program);\n  gl.uniform1i(u.mask, 0);\n  gl.uniform2f(u.shadow, SHADOW_X, SHADOW_Y);\n\n  return {\n    resize: (w, h, cssW, cssH) => {\n      gl.viewport(0, 0, w, h);\n      gl.uniform2f(u.res, cssW, cssH);\n    },\n    setMask: (source) => {\n      gl.activeTexture(gl.TEXTURE0);\n      gl.bindTexture(gl.TEXTURE_2D, mask);\n      gl.texImage2D(\n        gl.TEXTURE_2D,\n        0,\n        gl.RGBA,\n        gl.RGBA,\n        gl.UNSIGNED_BYTE,\n        source\n      );\n    },\n    setColors: (water, light) => {\n      gl.uniform3f(u.water, water[0], water[1], water[2]);\n      gl.uniform3f(u.light, light[0], light[1], light[2]);\n    },\n    setRipples: (data) => {\n      gl.uniform4fv(u.ripples, data);\n    },\n    draw: (time) => {\n      gl.uniform1f(u.time, time);\n      gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);\n    },\n    destroy: () => {\n      gl.deleteTexture(mask);\n      gl.deleteBuffer(quad);\n      gl.deleteProgram(program);\n      gl.deleteShader(vertex);\n      gl.deleteShader(fragment);\n    },\n  };\n}\n\n/**\n * Paints everything that floats (`data-shadow` elements) into a soft mask,\n * in the root's coordinates, for the floor shadow.\n */\nfunction drawMask(canvas: HTMLCanvasElement, root: HTMLElement) {\n  const rect = root.getBoundingClientRect();\n  canvas.width = Math.max(1, Math.round(rect.width));\n  canvas.height = Math.max(1, Math.round(rect.height));\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) {\n    return;\n  }\n  ctx.fillStyle = \"black\";\n  ctx.fillRect(0, 0, canvas.width, canvas.height);\n  ctx.filter = `blur(${SHADOW_BLUR}px)`;\n  ctx.fillStyle = \"white\";\n  for (const el of root.querySelectorAll<HTMLElement>(\"[data-shadow]\")) {\n    const box = el.getBoundingClientRect();\n    const x = box.left - rect.left;\n    const y = box.top - rect.top;\n    if (el.dataset.shadow === \"text\") {\n      const style = getComputedStyle(el);\n      ctx.font = `${style.fontWeight} ${style.fontSize} ${style.fontFamily}`;\n      ctx.letterSpacing = style.letterSpacing;\n      ctx.textBaseline = \"alphabetic\";\n      const word = el.textContent ?? \"\";\n      const m = ctx.measureText(word);\n      const baseline =\n        y +\n        (box.height + m.fontBoundingBoxAscent - m.fontBoundingBoxDescent) / 2;\n      ctx.fillText(word, x, baseline);\n    } else {\n      const radius = Number.parseFloat(getComputedStyle(el).borderRadius) || 0;\n      ctx.beginPath();\n      ctx.roundRect(x, y, box.width, box.height, radius);\n      ctx.fill();\n    }\n  }\n}\n\nexport default function CausticsBackground({\n  title = \"Swim before work.\",\n  children,\n  interactive = true,\n  className,\n}: CausticsBackgroundProps) {\n  const rootRef = useRef<HTMLDivElement>(null);\n  const canvasRef = useRef<HTMLCanvasElement>(null);\n  const waterRef = useRef<HTMLSpanElement>(null);\n  const lightRef = useRef<HTMLSpanElement>(null);\n\n  useEffect(() => {\n    const root = rootRef.current;\n    const canvas = canvasRef.current;\n    const gl = canvas?.getContext(\"webgl2\", {\n      alpha: false,\n      antialias: false,\n      depth: false,\n    });\n    const probe = document\n      .createElement(\"canvas\")\n      .getContext(\"2d\", { willReadFrequently: true });\n    const water = gl ? createWater(gl) : null;\n    if (!(root && canvas && probe && water)) {\n      return;\n    }\n    const reduceMotion = window.matchMedia(\n      \"(prefers-reduced-motion: reduce)\"\n    ).matches;\n    const maskCanvas = document.createElement(\"canvas\");\n    const ripples = new Float32Array(RIPPLES * 4);\n    const born = performance.now();\n    const clock = () => (performance.now() - born) / 1000;\n    let next = 0;\n    let raf = 0;\n    let visible = true;\n    let colorKey = \"\";\n    let width = 0;\n    let height = 0;\n    const hand = { x: 0, y: 0, t: 0, wake: 0 };\n\n    const drop = (x: number, y: number, strength: number) => {\n      ripples.set([x, y, clock(), strength], next * 4);\n      next = (next + 1) % RIPPLES;\n      water.setRipples(ripples);\n    };\n\n    const readColors = () => {\n      const w = getComputedStyle(waterRef.current as Element).color;\n      const l = getComputedStyle(lightRef.current as Element).color;\n      const key = `${w}|${l}`;\n      if (key === colorKey) {\n        return false;\n      }\n      colorKey = key;\n      water.setColors(toRgb(w, probe), toRgb(l, probe));\n      return true;\n    };\n\n    const build = () => {\n      const rect = root.getBoundingClientRect();\n      width = rect.width;\n      height = rect.height;\n      if (width < 2 || height < 2) {\n        return;\n      }\n      const dpr = Math.min(window.devicePixelRatio || 1, 1.5);\n      canvas.width = Math.round(width * dpr);\n      canvas.height = Math.round(height * dpr);\n      water.resize(canvas.width, canvas.height, width, height);\n      drawMask(maskCanvas, root);\n      water.setMask(maskCanvas);\n      water.draw(reduceMotion ? 8 : clock());\n    };\n\n    const frame = () => {\n      raf = 0;\n      if (!visible) {\n        return;\n      }\n      water.draw(clock());\n      raf = requestAnimationFrame(frame);\n    };\n    const start = () => {\n      if (!raf && visible && !reduceMotion) {\n        raf = requestAnimationFrame(frame);\n      }\n    };\n\n    readColors();\n    water.setRipples(ripples);\n    build();\n    canvas.style.opacity = \"1\";\n    start();\n\n    // One drop when the page opens, so the water shows it can be touched.\n    const firstDrop = window.setTimeout(() => {\n      if (!reduceMotion && interactive) {\n        drop(width * 0.68, height * 0.3, 1.1);\n      }\n    }, 500);\n\n    // Web fonts can land after the first mask; draw it again when they do.\n    document.fonts?.ready.then(() => {\n      if (rootRef.current) {\n        build();\n      }\n    });\n\n    const resizeObserver = new ResizeObserver(() => build());\n    resizeObserver.observe(root);\n    // A tab coming back must not wake a background that's scrolled away.\n    let onScreen = true;\n    const intersection = new IntersectionObserver(([entry]) => {\n      onScreen = entry.isIntersecting;\n      visible = onScreen && document.visibilityState === \"visible\";\n      start();\n    });\n    intersection.observe(root);\n    const onVisibility = () => {\n      visible = onScreen && document.visibilityState === \"visible\";\n      start();\n    };\n    document.addEventListener(\"visibilitychange\", onVisibility);\n\n    // Follow light and dark mode without asking the app for a theme. Only\n    // checked while the background is on screen.\n    const colorTimer = window.setInterval(() => {\n      if (!visible) {\n        return;\n      }\n      if (readColors() && (reduceMotion || !raf)) {\n        water.draw(reduceMotion ? 8 : clock());\n      }\n    }, 500);\n\n    const local = (event: PointerEvent) => {\n      const rect = root.getBoundingClientRect();\n      return { x: event.clientX - rect.left, y: event.clientY - rect.top };\n    };\n    const onMove = (event: PointerEvent) => {\n      const p = local(event);\n      const now = event.timeStamp;\n      const dt = Math.max(1, now - hand.t);\n      const speed = Math.hypot(p.x - hand.x, p.y - hand.y) / dt;\n      // A quick pass over the water leaves a trail of small rings.\n      if (hand.t && speed > WAKE_SPEED && now - hand.wake > WAKE_EVERY) {\n        drop(p.x, p.y, Math.min(0.45, speed * 0.25));\n        hand.wake = now;\n      }\n      hand.x = p.x;\n      hand.y = p.y;\n      hand.t = now;\n    };\n    const onLeave = () => {\n      hand.t = 0;\n    };\n    const onDown = (event: PointerEvent) => {\n      if ((event.target as HTMLElement).closest(\"a, button, input, textarea\")) {\n        return;\n      }\n      const p = local(event);\n      drop(p.x, p.y, 1.4);\n    };\n    if (interactive && !reduceMotion) {\n      root.addEventListener(\"pointermove\", onMove);\n      root.addEventListener(\"pointerleave\", onLeave);\n      root.addEventListener(\"pointerdown\", onDown);\n    }\n\n    return () => {\n      cancelAnimationFrame(raf);\n      window.clearTimeout(firstDrop);\n      window.clearInterval(colorTimer);\n      resizeObserver.disconnect();\n      intersection.disconnect();\n      document.removeEventListener(\"visibilitychange\", onVisibility);\n      root.removeEventListener(\"pointermove\", onMove);\n      root.removeEventListener(\"pointerleave\", onLeave);\n      root.removeEventListener(\"pointerdown\", onDown);\n      water.destroy();\n    };\n  }, [interactive]);\n\n  const words = title.split(\" \");\n\n  return (\n    <div\n      className={cn(\n        \"relative min-h-[560px] w-full touch-pan-y overflow-hidden bg-[#cde5e4] text-zinc-900 dark:bg-[#06201f] dark:text-zinc-50\",\n        className\n      )}\n      ref={rootRef}\n    >\n      {/* Theme colors for the shader: the pool floor and its light. */}\n      <span\n        className=\"hidden text-[#cde5e4] dark:text-[#06201f]\"\n        ref={waterRef}\n      />\n      <span className=\"hidden text-white dark:text-[#3ea89d]\" ref={lightRef} />\n      <div aria-hidden=\"true\" className=\"pointer-events-none absolute inset-0\">\n        <canvas\n          className=\"h-full w-full opacity-0 transition-opacity duration-700 ease-out\"\n          ref={canvasRef}\n        />\n      </div>\n      <div className=\"relative flex min-h-[560px] flex-col items-center justify-center gap-6 px-6 py-16 text-center\">\n        <h2 className=\"max-w-3xl text-balance font-semibold text-5xl tracking-[-0.045em] sm:text-7xl\">\n          {words.map((word, i) => (\n            <span key={`${word}-${i.toString()}`}>\n              <span data-shadow=\"text\">{word}</span>\n              {i < words.length - 1 ? \" \" : null}\n            </span>\n          ))}\n        </h2>\n        {children === undefined ? <DefaultContent /> : children}\n      </div>\n    </div>\n  );\n}\n\nfunction DefaultContent() {\n  return (\n    <>\n      <p className=\"max-w-md text-balance text-base text-zinc-600 leading-relaxed dark:text-zinc-300\">\n        Book a lane at any of 40 city pools. Lanes open at 6am, and the first\n        swim is on us.\n      </p>\n      <div className=\"flex items-center gap-2\">\n        <a\n          className=\"inline-flex h-10 items-center rounded-full bg-zinc-900 px-5 font-medium text-sm text-white transition-colors duration-150 hover:bg-zinc-700 dark:bg-zinc-50 dark:text-zinc-900 dark:hover:bg-zinc-200\"\n          data-shadow=\"box\"\n          href=\"#book\"\n        >\n          Book a lane\n        </a>\n        <a\n          className=\"inline-flex h-10 items-center rounded-full px-4 font-medium text-sm text-zinc-700 transition-colors duration-150 hover:bg-zinc-900/5 hover:text-zinc-900 dark:text-zinc-300 dark:hover:bg-white/10 dark:hover:text-zinc-50\"\n          href=\"#pools\"\n        >\n          Find a pool\n        </a>\n      </div>\n    </>\n  );\n}\n",
      "path": "/components/kokonutui/caustics-background.tsx",
      "target": "components/kokonutui/caustics-background.tsx"
    }
  ]
}