{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "click-ripple",
  "type": "registry:block",
  "title": "Click Ripple",
  "description": "Water over a photo, driven by a wave equation on the GPU: a click drops a stone, a drag leaves a wake and light rain keeps the surface alive. The water is a plane in perspective with refraction and sun glints, and an optional mask keeps the rocks dry and bounces ripples off them.",
  "docs": "Click Ripple was added to public/vividsites/click-ripple.html. Paste its markup, <style> and <script> where you want it on the page (or open the file to see it alone). Customise it with the CSS variables at the top. Docs and live demo: https://vividsites.app/c/click-ripple",
  "categories": [
    "3d",
    "3d-effects"
  ],
  "files": [
    {
      "path": "components/vividsites/click-ripple.html",
      "type": "registry:file",
      "target": "public/vividsites/click-ripple.html",
      "content": "<!--\n  vividsites · click-ripple\n  Real water over a photo. A wave equation runs on the GPU: a click drops a\n  stone, a press-and-drag trails a wake, and a hovering pointer skims the surface.\n  Give it a horizon and the water becomes a plane in perspective, so rings squash\n  into ellipses and shrink toward the far shore. Give it a mask and only the water\n  moves, and the ripples bounce off the rocks.\n\n  Markup:\n    data-src       the photo\n    data-mask      optional greyscale mask, same framing as the photo (white = water)\n    data-horizon   optional, 0–1 from the photo's top: where the water plane meets the sky.\n                   Leave it out for flat, top-down water over the whole image.\n  Customise (CSS variables on .vs-click-ripple, re-read whenever its style changes):\n    --vs-click-ripple-light     colour of the glints on the wave crests\n    --vs-click-ripple-drop      size of a clicked drop (.4–2)\n    --vs-click-ripple-refract   how much the photo bends through the water (0–2.5)\n    --vs-click-ripple-calm      damping: how quickly ripples die, .95 choppy … .998 glassy\n    --vs-click-ripple-rain      idle drops per second (0 = still water)\n    --vs-click-ripple-wake      how much a hovering pointer disturbs the water (0 = none)\n    --vs-click-ripple-height    the component's height (default min(80svh, 720px))\n    --vs-click-ripple-radius    corner radius\n  JS:\n    el.vsClickRipple.set({ drop, refract, calm, rain, wake, light })   same as the variables above\n    el.vsClickRipple.drop(x, y, strength)   x/y in px relative to the element\n  Touch: a tap drops a stone. The element keeps touch-action: pan-y so the page still scrolls;\n  set touch-action: none on it if a finger dragged across should leave a wake instead.\n-->\n<div class=\"vs-click-ripple\" data-src=\"lagoon.webp\" data-mask=\"lagoon-mask.webp\" data-horizon=\".385\">\n  <img class=\"vs-click-ripple-img\" src=\"lagoon.webp\" alt=\"A milky-blue geothermal lagoon between moss-covered lava rocks at dusk\">\n  <canvas class=\"vs-click-ripple-canvas\" aria-hidden=\"true\"></canvas>\n</div>\n\n<style>\n  .vs-click-ripple {\n    --vs-click-ripple-light: #ffd9b8;\n    --vs-click-ripple-drop: 1;\n    --vs-click-ripple-refract: 1;\n    --vs-click-ripple-calm: .988;\n    --vs-click-ripple-rain: .6;\n    --vs-click-ripple-wake: .5;\n    --vs-click-ripple-height: min(80svh, 720px);\n    --vs-click-ripple-radius: 18px;\n    position: relative; width: 100%; height: var(--vs-click-ripple-height); min-height: 360px;\n    overflow: hidden; border-radius: var(--vs-click-ripple-radius); background: #0c1116;\n    isolation: isolate; cursor: crosshair; touch-action: pan-y; -webkit-tap-highlight-color: transparent;\n  }\n  .vs-click-ripple-img, .vs-click-ripple-canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }\n  .vs-click-ripple-img { object-fit: cover; }\n  .vs-click-ripple-canvas { opacity: 0; transition: opacity 700ms cubic-bezier(.16,1,.3,1); }\n  .vs-click-ripple[data-vs-state=\"ready\"] .vs-click-ripple-canvas { opacity: 1; }\n  .vs-click-ripple:focus-visible { outline: 2px solid var(--vs-click-ripple-light); outline-offset: -4px; }\n</style>\n\n<script>\n(() => {\n  const VS = `#version 300 es\n  in vec2 aP; out vec2 vUv;\n  void main(){ vUv = aP*.5+.5; gl_Position = vec4(aP,0.,1.); }`;\n\n  /* shared: where a cell of the water plane sits in the photo, and back */\n  const MAP = `\n  uniform vec2 uRes, uSize, uDomain;   /* domain: plane width, plane depth (camera heights) */\n  uniform float uHz, uZMax, uPersp;    /* uHz: horizon height on screen, 0–1 from the bottom */\n  vec2 coverS(){ float ra = uRes.x/uRes.y, ia = uSize.x/uSize.y; return ra > ia ? vec2(1., ia/ra) : vec2(ra/ia, 1.); }\n  vec2 toImage(vec2 uv){ return (uv-.5)*coverS() + .5; }\n  vec2 simToScreen(vec2 s){\n    if (uPersp < .5) return s;\n    float z = 1. + s.y*(uZMax-1.);\n    float d = 1./z;\n    float ax = uRes.x/uRes.y;\n    return vec2(.5 + (s.x-.5)*uDomain.x*uHz/z/ax, uHz*(1.-d));\n  }\n  /* returns sim uv, z in .z (1 = nearest), and w = 0 above the horizon */\n  vec4 screenToSim(vec2 uv){\n    if (uPersp < .5) return vec4(uv, 1., 1.);\n    float d = (uHz - uv.y)/uHz;\n    if (d <= .001) return vec4(0.,0.,1e3,0.);\n    float z = 1./d;\n    float ax = uRes.x/uRes.y;\n    return vec4(.5 + (uv.x-.5)*ax*z/uHz/uDomain.x, (z-1.)/(uZMax-1.), z, 1.);\n  }`;\n\n  const SIM = `#version 300 es\n  precision highp float;\n  uniform sampler2D uState, uMask; uniform vec2 uTexel; uniform float uDamp, uHasMask;\n  ${MAP}\n  in vec2 vUv; out vec4 o;\n  void main(){\n    vec4 s = texture(uState, vUv);\n    float avg = (texture(uState, vUv+vec2(uTexel.x,0.)).r + texture(uState, vUv-vec2(uTexel.x,0.)).r\n               + texture(uState, vUv+vec2(0.,uTexel.y)).r + texture(uState, vUv-vec2(0.,uTexel.y)).r) * .25;\n    s.g += (avg - s.r) * 2.;\n    s.g *= uDamp;\n    s.r += s.g;\n    s.r *= .9995;\n    float m = 1.;\n    if (uHasMask > .5) { vec2 iu = toImage(simToScreen(vUv)); m = smoothstep(.25, .6, texture(uMask, iu).r); }\n    o = vec4(s.r*m, s.g*m, 0., 1.);\n  }`;\n\n  const DROP = `#version 300 es\n  precision highp float;\n  uniform sampler2D uState; uniform vec4 uDrops[8]; uniform int uCount; uniform vec2 uAspect;\n  in vec2 vUv; out vec4 o;\n  void main(){\n    vec4 s = texture(uState, vUv);\n    for (int i = 0; i < 8; i++) {\n      if (i >= uCount) break;\n      vec4 d = uDrops[i];\n      float r = length((vUv - d.xy) * uAspect) / d.z;\n      float k = max(0., 1. - r);\n      k = .5 - cos(k * 3.14159265) * .5;\n      s.r += k * d.w;\n    }\n    o = s;\n  }`;\n\n  const DRAW = `#version 300 es\n  precision highp float;\n  uniform sampler2D uImg, uState, uMask; uniform vec2 uTexel;\n  uniform float uRefract, uHasMask; uniform vec3 uLight;\n  ${MAP}\n  in vec2 vUv; out vec4 o;\n  void main(){\n    vec2 iu = toImage(vUv);\n    vec4 sm = screenToSim(vUv);\n    vec3 base = texture(uImg, iu).rgb;\n    if (sm.w < .5 || sm.x < 0. || sm.x > 1. || sm.y > 1.) { o = vec4(base, 1.); return; }\n    float edge = smoothstep(0., .06, sm.x) * smoothstep(1., .94, sm.x) * smoothstep(1., .9, sm.y);\n    vec2 s = sm.xy;\n    float h = texture(uState, s).r;\n    vec2 t2 = uTexel*1.5;\n    float hx = (texture(uState, s+vec2(t2.x,0.)).r - texture(uState, s-vec2(t2.x,0.)).r) / 1.5;\n    float hy = (texture(uState, s+vec2(0.,t2.y)).r - texture(uState, s-vec2(0.,t2.y)).r) / 1.5;\n    float m = (uHasMask > .5 ? smoothstep(.2, .7, texture(uMask, iu).r) : 1.) * edge;\n    /* a plane seen at depth z: bends shrink with 1/z, vertical slopes foreshorten */\n    float near = 1./sm.z;\n    vec2 off = vec2(hx, hy * mix(1., .6, uPersp)) * uRefract * .16 * near * m;\n    off.x *= uRes.y/uRes.x;\n    vec3 c;\n    c.r = texture(uImg, iu - off*1.08).r;\n    c.g = texture(uImg, iu - off).g;\n    c.b = texture(uImg, iu - off*.92).b;\n    /* slopes in the plane (x right, y toward the far shore, z up): faces tilted toward the\n       low light on the horizon brighten, faces tilted away fall into shade */\n    vec3 n = normalize(vec3(-hx*38., -hy*38., 1.));\n    vec3 L = normalize(vec3(.12, .9, .42));\n    float ndl = dot(n, L) - L.z;\n    /* glints: a camera looking out over the water sees the light where the half vector meets the normal */\n    vec3 V = normalize(vec3(0., -mix(0., .75, uPersp), 1.));\n    vec3 Hv = normalize(L + V);\n    float nh = max(dot(n, Hv), 0.), nh0 = Hv.z;\n    float spec = pow(nh, 420.) - pow(nh0, 420.);\n    spec = max(spec, 0.) * smoothstep(.0005, .004, 1. - n.z);\n    float lum = dot(base, vec3(.2126,.7152,.0722));\n    c *= 1. + clamp(ndl * .9, -.16, .12) * m;\n    c += uLight * clamp(ndl, 0., 1.) * .07 * m * (.3 + lum);\n    c += uLight * spec * m * 1.4 * near;\n    o = vec4(c, 1.);\n  }`;\n\n  const reduce = matchMedia('(prefers-reduced-motion: reduce)');\n  const loadImg = src => new Promise((res, rej) => { const i = new Image(); i.decoding = 'async'; i.onload = () => res(i); i.onerror = rej; i.src = src; });\n\n  function mount(root) {\n    if (root.vsClickRipple) return;\n    const canvas = root.querySelector('.vs-click-ripple-canvas');\n    /* props are read from the CSS variables once, then again only when the element's style changes */\n    const props = {};\n    const toRgb = s => { const c = document.createElement('canvas').getContext('2d'); c.fillStyle = '#ffd9b8'; c.fillStyle = s || '#ffd9b8'; const h = c.fillStyle;\n      return h[0] === '#' ? [1, 3, 5].map(i => parseInt(h.slice(i, i + 2), 16) / 255) : (h.match(/[\\d.]+/g) || [1, .85, .72]).slice(0, 3).map(x => x / 255); };\n    function readProps() {\n      const cs = getComputedStyle(root);\n      const num = (n, d) => { const v = parseFloat(cs.getPropertyValue(n)); return Number.isFinite(v) ? v : d; };\n      props.drop = num('--vs-click-ripple-drop', 1); props.refract = num('--vs-click-ripple-refract', 1);\n      props.calm = Math.min(.999, Math.max(.9, num('--vs-click-ripple-calm', .988)));\n      props.rain = Math.max(0, num('--vs-click-ripple-rain', .6)); props.wake = num('--vs-click-ripple-wake', .5);\n      props.light = toRgb(cs.getPropertyValue('--vs-click-ripple-light').trim());\n    }\n    readProps();\n\n    const gl = canvas.getContext('webgl2', { antialias: false, alpha: false, powerPreference: 'high-performance' });\n    if (!gl) { root.dataset.vsState = 'nogl'; return; }\n    const persp = root.dataset.horizon != null && root.dataset.horizon !== '';\n    const horizonImg = persp ? Math.min(.95, Math.max(.02, parseFloat(root.dataset.horizon))) : 0;\n    const ZMAX = 9; let PLANE_W = 7;\n\n    let P = {}, quad, img = null, mask = null, tImg, tMask, fbo = [], cur = 0, simW = 2, simH = 2;\n    let W = 1, H = 1, raf = 0, visible = true, lost = false, acc = 0, last = 0, rainT = 0, idleUntil = 0;\n    const drops = [];\n\n    const sh = (t, s) => { const x = gl.createShader(t); gl.shaderSource(x, s); gl.compileShader(x);\n      if (!gl.getShaderParameter(x, gl.COMPILE_STATUS) && !gl.isContextLost()) throw new Error(gl.getShaderInfoLog(x)); return x; };\n    const prog = fs => { const p = gl.createProgram(); gl.attachShader(p, sh(gl.VERTEX_SHADER, VS)); gl.attachShader(p, sh(gl.FRAGMENT_SHADER, fs));\n      gl.bindAttribLocation(p, 0, 'aP'); gl.linkProgram(p);\n      const u = {}; const n = gl.getProgramParameter(p, gl.ACTIVE_UNIFORMS);\n      for (let i = 0; i < n; i++) { const k = gl.getActiveUniform(p, i).name.replace('[0]', ''); u[k] = gl.getUniformLocation(p, k); }\n      return { p, u }; };\n    function texFrom(im) { const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, im);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 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); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE); return t; }\n    function target() {\n      const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);\n      gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA16F, simW, simH, 0, gl.RGBA, gl.HALF_FLOAT, null);\n      gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); 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); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);\n      const f = gl.createFramebuffer(); gl.bindFramebuffer(gl.FRAMEBUFFER, f);\n      gl.framebufferTexture2D(gl.FRAMEBUFFER, gl.COLOR_ATTACHMENT0, gl.TEXTURE_2D, t, 0);\n      const ok = gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;\n      gl.clearColor(0,0,0,1); gl.clear(gl.COLOR_BUFFER_BIT); gl.bindFramebuffer(gl.FRAMEBUFFER, null);\n      if (!ok) throw new Error('no float render target'); return { t, f };\n    }\n    /* the plane is as wide as the frame is at ~3 camera heights out, so wide and tall frames both spend their cells well */\n    const planeWidth = () => Math.min(9, Math.max(1.5, (W / H) / screenHorizon() * 3.2));\n    function domain() { return persp ? [PLANE_W, ZMAX - 1] : [W / H, 1]; }\n    function buildSim() {\n      const [dw, dh] = domain(); const long = Math.min(1280, Math.max(768, Math.round(Math.max(W, H))));\n      simW = Math.max(8, Math.round(dw >= dh ? long : long * dw / dh)); simH = Math.max(8, Math.round(dh > dw ? long : long * dh / dw));\n      fbo.forEach(x => { gl.deleteTexture(x.t); gl.deleteFramebuffer(x.f); });\n      fbo = [target(), target()]; cur = 0;\n    }\n    function init() {\n      if (!gl.getExtension('EXT_color_buffer_float') && !gl.getExtension('EXT_color_buffer_half_float')) throw new Error('no float render target');\n      P.sim = prog(SIM); P.drop = prog(DROP); P.draw = prog(DRAW);\n      quad = gl.createVertexArray(); gl.bindVertexArray(quad);\n      const b = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, b);\n      gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);\n      gl.enableVertexAttribArray(0); gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);\n      if (img) tImg = texFrom(img); if (mask) tMask = texFrom(mask);\n      buildSim();\n    }\n    function screenHorizon() {\n      /* horizon given in photo space → screen space, through the cover crop */\n      if (!img) return .6;\n      const ra = W / H, ia = img.naturalWidth / img.naturalHeight; const sy = ra > ia ? ia / ra : 1;\n      return ((1 - horizonImg) - .5) / sy + .5;\n    }\n    function mapUniforms(u) {\n      gl.uniform2f(u.uRes, W, H); gl.uniform2f(u.uSize, img.naturalWidth, img.naturalHeight);\n      const d = domain(); gl.uniform2f(u.uDomain, d[0], d[1]);\n      gl.uniform1f(u.uHz, screenHorizon()); gl.uniform1f(u.uZMax, ZMAX); gl.uniform1f(u.uPersp, persp ? 1 : 0);\n    }\n    /* px in the element → sim uv (or null above the horizon) */\n    function toSim(x, y) {\n      const u = x / root.clientWidth, v = 1 - y / root.clientHeight;\n      if (!persp) return { s: [u, v], z: 1 };\n      const hz = screenHorizon(); const d = (hz - v) / hz; if (d <= .02) return null;\n      const z = 1 / d; const ax = W / H;\n      return { s: [.5 + (u - .5) * ax * z / hz / PLANE_W, (z - 1) / (ZMAX - 1)], z };\n    }\n    function drop(x, y, strength = 1, radius = 1) {\n      const p = toSim(x, y); if (!p) return;\n      const [dw, dh] = domain();\n      /* a drop is ~22px across on screen wherever it lands, so far drops are wider in plane units */\n      const px = 7.5 * radius * props.drop / root.clientHeight;\n      const r = persp ? px * p.z / screenHorizon() / dh : px;\n      drops.push([p.s[0], p.s[1], r, .34 * strength * props.drop]);\n      if (drops.length > 64) drops.splice(0, drops.length - 64);\n      idleUntil = performance.now() + 9000; kick();\n    }\n    function pass(p, dst) { gl.bindFramebuffer(gl.FRAMEBUFFER, dst ? dst.f : null); gl.viewport(0, 0, dst ? simW : W, dst ? simH : H); gl.drawArrays(gl.TRIANGLES, 0, 3); }\n\n    function frame(now) {\n      raf = 0; if (lost || !img) return;\n      const dt = Math.min(.05, (now - (last || now)) / 1000); last = now;\n      gl.bindVertexArray(quad);\n      const [dw, dh] = domain();\n      /* ambient rain */\n      const rain = reduce.matches ? 0 : props.rain;\n      if (rain > 0) { rainT -= dt; if (rainT <= 0) { rainT = (.4 + Math.random() * 1.2) / rain;\n        drop(Math.random() * root.clientWidth, root.clientHeight * (.45 + Math.random() * .55), .22 + Math.random() * .2, .55); } }\n      if (drops.length) {\n        while (drops.length) {\n          const batch = drops.splice(0, 8);\n          gl.useProgram(P.drop.p); const u = P.drop.u;\n          gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, fbo[cur].t); gl.uniform1i(u.uState, 0);\n          gl.uniform4fv(u.uDrops, new Float32Array(32).fill(0).map((_, i) => batch[i >> 2]?.[i & 3] ?? 0));\n          gl.uniform1i(u.uCount, batch.length); gl.uniform2f(u.uAspect, dw / dh, 1);\n          pass(P.drop, fbo[1 - cur]); cur = 1 - cur;\n        }\n      }\n      acc = Math.min(acc + dt, 1 / 30);\n      const damp = props.calm;\n      gl.useProgram(P.sim.p); const su = P.sim.u; mapUniforms(su);\n      gl.uniform2f(su.uTexel, 1 / simW, 1 / simH); gl.uniform1f(su.uDamp, damp); gl.uniform1f(su.uHasMask, tMask ? 1 : 0);\n      gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, tMask || tImg); gl.uniform1i(su.uMask, 1);\n      while (acc >= 1 / 120) {\n        acc -= 1 / 120;\n        gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, fbo[cur].t); gl.uniform1i(su.uState, 0);\n        pass(P.sim, fbo[1 - cur]); cur = 1 - cur;\n      }\n      gl.useProgram(P.draw.p); const du = P.draw.u; mapUniforms(du);\n      gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, tImg); gl.uniform1i(du.uImg, 0);\n      gl.activeTexture(gl.TEXTURE1); gl.bindTexture(gl.TEXTURE_2D, fbo[cur].t); gl.uniform1i(du.uState, 1);\n      gl.activeTexture(gl.TEXTURE2); gl.bindTexture(gl.TEXTURE_2D, tMask || tImg); gl.uniform1i(du.uMask, 2);\n      gl.uniform1f(du.uHasMask, tMask ? 1 : 0); gl.uniform2f(du.uTexel, 1 / simW, 1 / simH);\n      gl.uniform1f(du.uRefract, props.refract); gl.uniform3f(du.uLight, ...props.light);\n      pass(P.draw, null);\n      if (root.dataset.vsState !== 'ready') root.dataset.vsState = 'ready';\n      /* keep running while there is rain or the water is still settling */\n      if (visible && (rain > 0 || now < idleUntil)) raf = requestAnimationFrame(frame);\n    }\n    const kick = () => { if (!raf && visible && !lost && img) { last = 0; raf = requestAnimationFrame(frame); } };\n\n    function resize() {\n      const dpr = Math.min(2, devicePixelRatio || 1);\n      const w = Math.max(1, Math.round(root.clientWidth * dpr)), h = Math.max(1, Math.round(root.clientHeight * dpr));\n      if (w === W && h === H) return; W = canvas.width = w; H = canvas.height = h;\n      if (persp) PLANE_W = planeWidth();\n      if (fbo.length) buildSim();\n      idleUntil = performance.now() + 500; kick();\n    }\n\n    /* input: click drops, press-drag wakes, hover skims */\n    let down = false, lx = 0, ly = 0, lt = 0;\n    const local = e => { const r = root.getBoundingClientRect(); return [e.clientX - r.left, e.clientY - r.top]; };\n    root.addEventListener('pointerdown', e => { const [x, y] = local(e); down = true; lx = x; ly = y; drop(x, y, 1.1, 1.25); });\n    addEventListener('pointerup', () => { down = false; }, { passive: true });\n    root.addEventListener('pointermove', e => {\n      const [x, y] = local(e); const now = performance.now();\n      const dist = Math.hypot(x - lx, y - ly);\n      const wake = props.wake;\n      if (dist > 14 && (down || (e.pointerType === 'mouse' && wake > 0))) {\n        const speed = Math.min(1, dist / Math.max(8, (now - lt)) / 2);\n        drop(x, y, down ? .45 : wake * (.12 + .25 * speed), down ? .8 : .6);\n        lx = x; ly = y; lt = now;\n      }\n    }, { passive: true });\n    root.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); drop(root.clientWidth * (.3 + Math.random() * .4), root.clientHeight * .75, 1.1, 1.25); } });\n\n    const VARS = { drop: 'drop', refract: 'refract', calm: 'calm', rain: 'rain', wake: 'wake', light: 'light' };\n    function set(o = {}) {\n      for (const k in o) if (VARS[k] && o[k] != null) root.style.setProperty('--vs-click-ripple-' + VARS[k], o[k]);\n      readProps(); idleUntil = performance.now() + 1500; kick();\n    }\n    new MutationObserver(() => { readProps(); kick(); }).observe(root, { attributes: true, attributeFilter: ['style', 'class'] });\n\n    canvas.addEventListener('webglcontextlost', e => { e.preventDefault(); lost = true; cancelAnimationFrame(raf); raf = 0; root.dataset.vsState = 'lost'; });\n    canvas.addEventListener('webglcontextrestored', () => { lost = false; try { init(); root.dataset.vsState = 'ready'; kick(); } catch { root.dataset.vsState = 'nogl'; } });\n    new IntersectionObserver(([en]) => { visible = en.isIntersecting; if (visible) kick(); }).observe(root);\n    document.addEventListener('visibilitychange', () => { visible = !document.hidden; if (visible) kick(); });\n\n    Promise.all([loadImg(root.dataset.src), root.dataset.mask ? loadImg(root.dataset.mask).catch(() => null) : null]).then(([i, m]) => {\n      img = i; mask = m;\n      try { init(); } catch (err) { root.dataset.vsState = 'nogl'; console.warn('[click-ripple]', err.message); return; }\n      new ResizeObserver(resize).observe(root); resize();\n      root.vsClickRipple = { drop: (x, y, s = 1) => drop(x, y, s, 1.25), set };\n      root.dispatchEvent(new CustomEvent('vs:ready'));\n      idleUntil = performance.now() + 1000; kick();\n    }).catch(() => { root.dataset.vsState = 'nogl'; });\n    if (!root.hasAttribute('tabindex')) root.tabIndex = 0;\n  }\n  const boot = () => document.querySelectorAll('.vs-click-ripple').forEach(mount);\n  document.readyState === 'loading' ? document.addEventListener('DOMContentLoaded', boot) : boot();\n})();\n</script>\n"
    }
  ],
  "meta": {
    "category": "3d",
    "group": "3D effects",
    "tags": [
      "water",
      "ripple",
      "wave equation",
      "simulation",
      "photo",
      "click",
      "perspective",
      "refraction"
    ],
    "tech": [
      "GLSL",
      "JS",
      "CSS"
    ],
    "deps": [],
    "source": "hraun",
    "free": true,
    "accent": "#8fd3e0",
    "added": "2026-09-30",
    "demo": "https://vividsites.app/api/component/click-ripple/index.html",
    "thumbnail": "https://vividsites.app/api/component/click-ripple/thumb.jpg",
    "video": "https://vividsites.app/api/component/click-ripple/thumb.mp4",
    "page": "https://vividsites.app/c/click-ripple"
  }
}