---
name: ripple-shader
description: Use when you want "Interactive, premium, fluid" - Creates water-like ripples from clicks, hover, or motion.
---

# Ripple Shader

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Interactive, premium, fluid
>
> **Best use:** Image galleries, hero sections
>
> **Ratings:** Professional 4/5 - Casual 4/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A GPU fragment shader that turns a photo into a sheet of water: every click, tap or pointer-drag drops a ripple that expands as a damped, concentric ring, **refracts the image along its radius** and leaves an aqua specular glint on the crest. Because the work happens per-pixel on the GPU, dozens of overlapping ripples cost the same as one, and the surface stays glassy at 60fps. It is the premium, tactile centrepiece for a hero image or an image gallery — distinct from the Material "Ripple Effect" (a DOM circle on a button); this one bends real photons of the texture.

This is the **analytic** approach (a small ring-buffer of ripples summed in-shader), not a ping-pong height-field simulation — it needs no render targets, is deterministic, and degrades cleanly to a static frame.

## Dependencies / CDN

three.js (pinned, UMD global `THREE`) with Subresource Integrity, plus two display/UI fonts:

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=Schibsted+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">

<script src="https://cdn.jsdelivr.net/npm/three@0.160.0/build/three.min.js"
        integrity="sha384-qOkzR5Ke/XkQxuGVJ9hpFEpDlcoLtWwVYhnJf06cLIZa2vaIptSqaubivErzmD5O"
        crossorigin="anonymous" referrerpolicy="no-referrer"></script>
```

> r160 prints a one-line "build/three.min.js is deprecated" console **warning** — it is harmless and still works. (Newer releases dropped the non-module build; if you upgrade, switch to an ES-module import + import-map.)

## HTML

The image sits in the DOM **behind** the canvas, so it doubles as the WebGL-failure fallback. Overlay UI is `pointer-events:none` so clicks reach the canvas.

```html
<section class="rs-stage">
  <div class="rs-fallback" aria-hidden="true"></div>   <!-- shown if WebGL is unavailable -->
  <canvas class="rs-canvas" aria-hidden="true"></canvas>
  <div class="rs-rings" aria-hidden="true"><span></span><span></span><span></span></div>
  <div class="rs-scrim" aria-hidden="true"></div>      <!-- gradient for text legibility -->

  <div class="rs-ui">
    <h2 class="rs-h">The shoreline <em>answers</em> every touch.</h2>
    <span class="rs-hint">Click or drag the water — <b>it ripples back</b></span>
  </div>
</section>
```

## CSS

Only the load-bearing rules — the fallback layering and the no-WebGL / reduced-motion branches:

```css
.rs-stage{position:relative;border-radius:28px;overflow:hidden;isolation:isolate;
  height:clamp(548px,76vh,742px);background:#070d12}

/* image behind the canvas = the WebGL-failure fallback */
.rs-fallback{position:absolute;inset:0;z-index:0;background:center/cover no-repeat;
  background-image:url(/path/landscape-coast.jpg)}
.rs-canvas{position:absolute;inset:0;z-index:1;width:100%;height:100%;display:block;
  opacity:0;transition:opacity .6s ease;cursor:crosshair;touch-action:pan-y}
.rs-ready  .rs-canvas{opacity:1}     /* fade in once the first texture is decoded */
.rs-nogl   .rs-canvas{display:none}  /* WebGL missing -> reveal the fallback image */

/* CSS-only decorative ripple, shown ONLY when WebGL is unavailable */
.rs-rings{position:absolute;inset:0;z-index:1;display:none;place-items:center;pointer-events:none}
.rs-nogl .rs-rings{display:grid}
.rs-rings span{position:absolute;width:46px;height:46px;border-radius:50%;
  border:1.5px solid rgba(143,230,216,.55);animation:rs-ring 3.6s ease-out infinite}
.rs-rings span:nth-child(2){animation-delay:1.2s}
.rs-rings span:nth-child(3){animation-delay:2.4s}
@keyframes rs-ring{0%{transform:scale(.16);opacity:.75}100%{transform:scale(13);opacity:0}}

/* overlay must not eat pointer events meant for the canvas */
.rs-ui{position:absolute;inset:0;z-index:3;pointer-events:none}
.rs-ui a,.rs-ui button{pointer-events:auto}

@media (prefers-reduced-motion: reduce){
  .rs-canvas{opacity:1;transition:none;cursor:default}  /* one static frame, no fade */
  .rs-rings span{display:none}
}
```

## JavaScript

The full effect: feature-detect → build an orthographic full-screen quad → load the texture → stamp ripples into a 12-slot ring buffer on pointer events → animate. **The two shaders below are verbatim.**

```js
(function(){
  var stage  = document.querySelector('.rs-stage'),
      canvas = stage.querySelector('.rs-canvas'),
      IMG    = '/path/landscape-coast.jpg';
  var reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;

  function hasWebGL(){
    try{ var c=document.createElement('canvas');
      return !!(window.WebGLRenderingContext && (c.getContext('webgl')||c.getContext('experimental-webgl'))); }
    catch(e){ return false; }
  }
  // No three.js (CDN/SRI failure) or no WebGL -> CSS fallback (image + .rs-rings)
  if(!window.THREE || !hasWebGL()){ stage.classList.add('rs-nogl'); return; }

  var MAX = 12;
  var renderer = new THREE.WebGLRenderer({canvas:canvas, antialias:true, alpha:false});
  renderer.setPixelRatio(Math.min(window.devicePixelRatio||1, 2));
  renderer.setClearColor(0x070d12, 1);
  var scene  = new THREE.Scene();
  var camera = new THREE.OrthographicCamera(-1,1,1,-1,0,1);

  var ripples = [];
  for(var i=0;i<MAX;i++) ripples.push(new THREE.Vector4(0,0,-1e3,0)); // xy, birth, amp

  var uniforms = {
    uTex:{value:null}, uRes:{value:new THREE.Vector2(1,1)}, uImg:{value:new THREE.Vector2(1,1)},
    uTime:{value:0}, uAqua:{value:new THREE.Color(0x9bf0e2)}, uRipples:{value:ripples}
  };

  var vert = `
    varying vec2 vUv;
    void main(){ vUv = uv; gl_Position = vec4(position.xy, 0.0, 1.0); }`;

  var frag = `
    precision highp float;
    #define MAX 12
    varying vec2 vUv;
    uniform sampler2D uTex;
    uniform vec2 uRes;            // canvas size (px) - only the ratio matters
    uniform vec2 uImg;            // source image size (px)
    uniform float uTime;
    uniform vec3 uAqua;
    uniform vec4 uRipples[MAX];   // xy = origin (uv, y-up), z = birth time, w = amplitude

    // background-size:cover mapping, so the photo never stretches
    vec2 coverUV(vec2 uv, vec2 res, vec2 img){
      float sc = max(res.x/img.x, res.y/img.y);
      vec2 size = img * sc;
      return (uv * res - (res - size) * 0.5) / size;
    }
    void main(){
      float aspect = uRes.x / uRes.y;
      vec2 disp = vec2(0.0);       // accumulated refraction offset
      float crest = 0.0;           // accumulated wave height (for the glint)
      for(int i=0;i<MAX;i++){
        vec4 r = uRipples[i];
        if(r.w <= 0.0) continue;
        float age = uTime - r.z;
        if(age < 0.0) continue;
        vec2 d = vUv - r.xy; d.x *= aspect;   // circular distance, not elliptical
        float dist = length(d);
        float w = dist - age * 0.5;           // signed distance to the expanding front
        float wave = sin(w * 70.0);           // the wave train
        float band = exp(-w * w * 50.0);      // gaussian hugging the front
        float fade = exp(-age * 1.5);         // ripple dies over ~2s
        float h = r.w * wave * band * fade;
        crest += h;
        disp += normalize(d + 1e-6) * h;      // push texels along the radius
      }
      vec2 uv = coverUV(vUv, uRes, uImg);
      vec3 col = texture2D(uTex, uv + disp * 0.02).rgb;
      col += uAqua * max(crest, 0.0) * 0.42;  // sunlit crest glint
      col -= vec3(0.05) * max(-crest, 0.0);   // shadowed trough
      gl_FragColor = vec4(col, 1.0);
    }`;

  var mat = new THREE.ShaderMaterial({uniforms:uniforms, vertexShader:vert, fragmentShader:frag});
  scene.add(new THREE.Mesh(new THREE.PlaneGeometry(2,2), mat));

  // ---- ripple ring buffer ----
  var head = 0;
  function emit(nx, ny, amp){ ripples[head].set(nx, ny, uniforms.uTime.value, amp); head=(head+1)%MAX; }

  // ---- texture (swap the url to switch scenes in a gallery) ----
  new THREE.TextureLoader().load(IMG, function(tex){
    tex.colorSpace = THREE.SRGBColorSpace;
    tex.minFilter = tex.magFilter = THREE.LinearFilter; tex.generateMipmaps = false;
    uniforms.uTex.value = tex;
    uniforms.uImg.value.set(tex.image.width, tex.image.height);
    stage.classList.add('rs-ready');
    if(reduce) renderer.render(scene, camera);   // static frame, no loop
  });

  // ---- size to the stage (CSS controls layout; only the ratio matters to the shader) ----
  function resize(){
    var w = stage.clientWidth, h = stage.clientHeight; if(!w||!h) return;
    renderer.setSize(w, h, false);
    uniforms.uRes.value.set(w, h);
    if(reduce && uniforms.uTex.value) renderer.render(scene, camera);
  }
  new ResizeObserver(resize).observe(stage); resize();

  // ---- reduced motion = static: no pointer ripples, no animation loop ----
  if(reduce) return;

  function uv(e){ var b=canvas.getBoundingClientRect();
    return [ (e.clientX-b.left)/b.width, 1.0-(e.clientY-b.top)/b.height ]; }  // y-up
  var last = 0;
  stage.addEventListener('pointerdown', function(e){ var p=uv(e); emit(p[0],p[1],0.95); });
  stage.addEventListener('pointermove', function(e){
    var now=performance.now(); if(now-last<42) return; last=now;   // throttle the wake
    var p=uv(e); emit(p[0],p[1],0.34);
  });

  var t0 = performance.now();
  (function tick(now){ uniforms.uTime.value=(now-t0)/1000; renderer.render(scene,camera); requestAnimationFrame(tick); })(t0);
})();
```

## How it works

- **One full-screen quad.** A `PlaneGeometry(2,2)` covers clip space; the vertex shader passes its `0..1` `uv` straight through, so the fragment shader runs once per output pixel.
- **`coverUV()`** reproduces CSS `background-size:cover` in GLSL — it scales by `max(res/img)` and centres, so the photo fills the stage at any aspect without stretching. Only the **ratio** of `uRes` matters, so feeding it CSS pixels (ignoring `devicePixelRatio`) is correct.
- **Each ripple is `vec4(originX, originY, birthTime, amplitude)`.** A 12-slot ring buffer holds the most recent ones; pointer events overwrite the oldest slot. The shader loops all 12 and, per pixel, builds a travelling wave: `sin(w·70)` is the wave train, the gaussian `exp(-w²·50)` confines it to a thin shell hugging the expanding front `dist - age·0.5`, and `exp(-age·1.5)` kills it over ~2s. `d.x *= aspect` keeps rings circular, not oval.
- **Refraction + glint.** The summed wave height pushes the sample point **along the radius** (`disp`), so the image bends as if seen through moving water; the positive crest adds an aqua highlight and the trough darkens slightly, selling the "wet" look.
- **Graceful states.** WebGL/CDN missing → `.rs-nogl` reveals the underlying `<img>` plus a pure-CSS ripple ring. `prefers-reduced-motion` → render exactly one frame and bind no pointer/RAF handlers.

## Customization

- **Ripple feel:** `sin(w*70.0)` ring spacing (higher = tighter rings), `exp(-w*w*50.0)` shell thickness (lower = wider wake), `exp(-age*1.5)` lifetime, and `age*0.5` the expansion speed.
- **Refraction strength:** the `disp * 0.02` multiplier — raise toward `0.04` for a heavier lens, drop for a subtle shimmer.
- **Click vs. drag energy:** the `amp` passed to `emit()` (`0.95` on `pointerdown`, `0.34` on throttled `pointermove`).
- **Tint:** `uAqua` and the `0.42` glint gain; warm it up for a "sunset on water" read.
- **Gallery:** call the loader again with a new URL to cross-fade scenes; `emit(0.5,0.54,0.7)` on switch drops a welcoming ripple at centre.

## Accessibility & performance

- **Reduced motion is a hard branch:** no animation loop and no pointer emitters are even attached — a single static, undistorted frame is rendered.
- **Pause when off-screen:** wrap the RAF loop in an `IntersectionObserver` (the live demo does) so a hero below the fold burns no GPU.
- `setPixelRatio(Math.min(dpr, 2))` caps the fragment count on 3x phones; `generateMipmaps=false` + `LinearFilter` avoids a needless mip pass on a quad that's never minified.
- `pointermove` is throttled (~24Hz) so the ring buffer isn't thrashed; the buffer is fixed-size (12) so memory is constant no matter how long someone scrubs.
- The canvas is decorative (`aria-hidden`); all meaning lives in the real DOM text above it, and keyboard users never get trapped.

## Gotchas

- **`build/three.min.js` is the UMD global build** (`window.THREE`). It logs a deprecation **warning** at r160 but works; do not confuse the warning with an error. If you bump three.js past r160 the file is gone — switch to an ES-module import.
- **Overlay UI will swallow your clicks.** The element stacked above the canvas must be `pointer-events:none` (re-enable `auto` only on its buttons/links) or no ripples appear.
- **Map pointer Y with a flip:** GL `uv` is y-up, DOM `clientY` is y-down — use `1.0 - y/height` or every ripple lands mirrored.
- **`touch-action`:** set `pan-y` (not `none`) on the canvas so visitors can still scroll the page past a tall hero while horizontal drags still ripple.
- **Colour space:** set `texture.colorSpace = THREE.SRGBColorSpace` (renderer output is sRGB by default in r160) or the photo looks washed-out/dark.
- **Inactive slots:** initialise ripple amplitude to `0` (and birth time far in the past) and `continue` on `r.w <= 0.0`; otherwise empty slots draw a stray ring at the origin.
- **Don't animate the blur/oscillator on the CPU.** All motion is `uTime` in the shader — the JS only stamps `(x,y,birth,amp)` and renders.
