---
name: wave-shader
description: Use when you want "Elegant, fluid, futuristic" - Creates animated wave patterns or water-like displacement.
---

# Wave Shader

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Elegant, fluid, futuristic
>
> **Best use:** Product pages, hero backgrounds
>
> **Ratings:** Professional 4/5 - Casual 3/5 - Exotic 4/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

A real, geometric water surface: a finely tessellated plane whose vertices are raised by a sum of moving sine waves **in the vertex shader**, then lit per-pixel **in the fragment shader** with a fresnel rim, a sharp specular sun-glint and an iridescent thin-film palette. Unlike a flat fragment-only "fake" wave, the mesh genuinely deforms in 3D, so crests catch light and the far surface recedes to a foggy horizon. It reads as elegant, fluid and futuristic, which makes it a natural hero background for premium product, skincare, wellness, audio or fintech pages.

## Dependencies / CDN

three.js r160 (pinned, with Subresource Integrity). Add it before your script:

```html
<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>
```

Demo fonts (optional): **Instrument Serif** (display) + **Hanken Grotesk** (UI).

```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=Hanken+Grotesk:wght@400;500;600;700&family=Instrument+Serif:ital@0;1&display=swap" rel="stylesheet">
```

## HTML

The canvas sits inside a rounded, clipped stage. The stage's own CSS gradient doubles as the **WebGL-failure fallback** ("a sea at dusk"); any UI lives above a scrim for legibility.

```html
<div class="ws-stage" id="wsStage">
  <canvas class="ws-canvas" id="wsCanvas" aria-hidden="true"></canvas>
  <div class="ws-scrim" aria-hidden="true"></div>
  <div class="ws-ui">
    <!-- nav + hero copy + a glass product card go here -->
  </div>
</div>
```

## CSS

Only the lines that make the effect (and its fallback) work — the rest is theming.

```css
.ws-stage{position:relative;border-radius:26px;overflow:hidden;min-height:648px;isolation:isolate;
  /* this gradient is the WebGL-fail fallback: sky -> bright horizon -> teal water */
  background:
    radial-gradient(120% 70% at 50% 47%, rgba(205,238,242,.55), rgba(205,238,242,0) 42%),
    linear-gradient(180deg,#040a1c 0%,#071633 26%,#16557a 44%,#cdeef2 50%,#1f9fb6 56%,#0a2747 78%,#05132b 100%)}
.ws-canvas{position:absolute;inset:0;width:100%;height:100%;display:block;z-index:0;background:transparent}
.ws-is-fallback .ws-canvas{display:none}            /* JS adds .ws-is-fallback -> reveal the gradient */
.ws-scrim{position:absolute;inset:0;z-index:1;pointer-events:none;
  background:linear-gradient(104deg, rgba(4,10,26,.86) 0%, rgba(4,10,26,.05) 60%, transparent 78%)}
.ws-ui{position:absolute;inset:0;z-index:3;display:flex;flex-direction:column;padding:clamp(20px,3.1vw,42px)}

@media (prefers-reduced-motion: reduce){ .ws-h em, .ws-gwave{animation:none} }
```

## JavaScript

three.js prepends its own `position`/`uv` attributes, the `modelMatrix`/`viewMatrix`/`projectionMatrix`/`cameraPosition` uniforms and the float precision, so the shaders below declare only their own uniforms and varyings.

**Vertex shader** (verbatim) — raises the plane and computes a normal from the same wave field:

```glsl
uniform float uTime;
uniform vec2  uMouse;
uniform float uMouseStr;
uniform float uFogNear;
uniform float uFogFar;

varying float vHeight;
varying vec3  vNormal;
varying vec3  vView;
varying float vFog;

// Summed directional sine waves (+ a pointer swell). The SAME field is sampled
// for the height and for the finite-difference normal, so lighting stays exact.
float waveSum(vec2 p){
  float t = uTime;
  float h = 0.0;
  h += 0.300 * sin(dot(p, vec2( 0.96, 0.29)) * 1.30 + t * 1.05);
  h += 0.170 * sin(dot(p, vec2(-0.57, 0.82)) * 2.10 + t * 0.90);
  h += 0.100 * sin(dot(p, vec2( 0.37,-0.93)) * 3.40 + t * 1.50);
  h += 0.055 * sin(dot(p, vec2( 0.71, 0.71)) * 5.30 + t * 1.90);
  float md = distance(p, uMouse);                       // pointer swell, falls off fast
  h += uMouseStr * 0.16 * sin(md * 6.0 - t * 3.4) * exp(-md * md * 0.7);
  return h;
}

void main(){
  vec3 pos = position;
  vec2 p   = pos.xy;

  float h = waveSum(p);
  pos.z  += h;            // displace along the plane's local normal
  vHeight = h;

  // surface normal from central differences of the wave field
  float e  = 0.12;
  float hL = waveSum(p - vec2(e, 0.0));
  float hR = waveSum(p + vec2(e, 0.0));
  float hD = waveSum(p - vec2(0.0, e));
  float hU = waveSum(p + vec2(0.0, e));
  vec3  nLocal = normalize(vec3(hL - hR, hD - hU, 2.0 * e));
  vNormal = normalize(mat3(modelMatrix) * nLocal);

  vec4 world = modelMatrix * vec4(pos, 1.0);
  vView = normalize(cameraPosition - world.xyz);

  vec4 mv = viewMatrix * world;
  vFog = smoothstep(uFogNear, uFogFar, -mv.z);          // fade the far surface into the sky
  gl_Position = projectionMatrix * mv;
}
```

**Fragment shader** (verbatim) — iridescent water shading:

```glsl
uniform float uTime;
uniform vec3  uDeep;      // deep trough colour
uniform vec3  uShallow;   // bright crest colour
uniform vec3  uIrid;      // iridescent rim tint
uniform vec3  uSky;       // atmosphere colour for the horizon blend

varying float vHeight;
varying vec3  vNormal;
varying vec3  vView;
varying float vFog;

void main(){
  vec3 N = normalize(vNormal);
  vec3 V = normalize(vView);
  vec3 L = normalize(vec3(-0.35, 0.72, 0.45));          // key light, upper-back

  // body colour: deep troughs -> bright crests
  float hN = clamp(vHeight * 1.5 + 0.5, 0.0, 1.0);
  vec3  col = mix(uDeep, uShallow, smoothstep(0.12, 0.95, hN));

  // wrapped diffuse so troughs never crush to black
  float diff = clamp(dot(N, L) * 0.5 + 0.5, 0.0, 1.0);
  col *= 0.55 + 0.70 * diff;

  // fresnel rim — strongest at grazing angles near the horizon
  float fres = pow(1.0 - clamp(dot(N, V), 0.0, 1.0), 3.5);

  // iridescent thin-film: per-channel cosine palette driven by fresnel/height/time
  vec3 irid = 0.5 + 0.5 * cos(6.28318 * (fres * 1.10 + hN * 0.50 + uTime * 0.03 + vec3(0.00, 0.20, 0.42)));
  col = mix(col, irid, fres * 0.55);
  col += uIrid * fres * 0.55;

  // specular sun glint (Blinn-Phong)
  vec3  H = normalize(L + V);
  float spec = pow(clamp(dot(N, H), 0.0, 1.0), 90.0);
  col += vec3(1.0, 0.97, 0.92) * spec * 0.9;

  // sparkle on the highest crests
  col += uShallow * smoothstep(0.78, 1.0, hN) * 0.18;

  // blend the far surface into the atmosphere -> soft horizon
  col = mix(col, uSky, vFog);

  // dither to kill banding on the smooth gradients
  float d = fract(sin(dot(gl_FragCoord.xy, vec2(12.9898, 78.233))) * 43758.5453);
  col += (d - 0.5) * 0.025;

  gl_FragColor = vec4(clamp(col, 0.0, 1.0), 1.0);
}
```

**Setup + loop** (the `VERT`/`FRAG` strings above are assigned verbatim):

```js
(function(){
  var stage  = document.getElementById('wsStage');
  var canvas = document.getElementById('wsCanvas');
  if(!stage || !canvas) return;
  var reduce = !!(window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches);

  function bail(){ stage.classList.add('ws-is-fallback'); }   // -> CSS gradient fallback
  if(typeof THREE === 'undefined'){ bail(); return; }         // CDN blocked

  var VERT = `...vertex shader above...`;
  var FRAG = `...fragment shader above...`;

  var renderer, scene, camera, mesh, uniforms;
  try{
    renderer = new THREE.WebGLRenderer({ canvas:canvas, antialias:true, alpha:true, powerPreference:'high-performance' });
    renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
    renderer.setClearColor(0x000000, 0);                      // transparent -> CSS sky shows above the water

    scene  = new THREE.Scene();
    camera = new THREE.PerspectiveCamera(46, 1, 0.1, 60);
    camera.position.set(0, 1.8, 6.6);
    camera.lookAt(0, 0.3, -3.0);

    uniforms = {
      uTime:{value:0}, uMouse:{value:new THREE.Vector2(0,0)}, uMouseStr:{value:0},
      uFogNear:{value:8.0}, uFogFar:{value:15.0},
      uDeep:    {value:new THREE.Vector3(0.024,0.071,0.180)},  // #06122e
      uShallow: {value:new THREE.Vector3(0.180,0.886,0.863)},  // #2ee2dc
      uIrid:    {value:new THREE.Vector3(1.000,0.612,0.863)},  // #ff9cdc
      uSky:     {value:new THREE.Vector3(0.725,0.902,0.925)}   // #b9e6ec
    };
    var mat = new THREE.ShaderMaterial({ vertexShader:VERT, fragmentShader:FRAG, uniforms:uniforms });
    mesh = new THREE.Mesh(new THREE.PlaneGeometry(18, 18, 256, 256), mat);
    mesh.rotation.x = -Math.PI / 2;                            // lay flat: local +Z (waves) -> world up
    scene.add(mesh);
    camera.updateMatrixWorld(); mesh.updateMatrixWorld();
  }catch(e){ bail(); return; }                                 // context / compile failure

  // pointer -> the plane's local coords via ray/plane intersection
  var ray = new THREE.Raycaster(), ndc = new THREE.Vector2(), hit = new THREE.Vector3();
  var seaPlane = new THREE.Plane(new THREE.Vector3(0,1,0), 0);
  var mouse = new THREE.Vector2(0,0), target = new THREE.Vector2(0,0), str = 0, targetStr = 0;

  function draw(t){
    uniforms.uTime.value = t;
    mouse.x += (target.x - mouse.x) * 0.09;
    mouse.y += (target.y - mouse.y) * 0.09;
    str += (targetStr - str) * 0.05;
    uniforms.uMouse.value.set(mouse.x, mouse.y);
    uniforms.uMouseStr.value = str;
    renderer.render(scene, camera);
  }
  function resize(){
    var w = stage.clientWidth, h = stage.clientHeight; if(!w || !h) return;
    renderer.setSize(w, h, false);
    camera.aspect = w / h; camera.updateProjectionMatrix();
    if(reduce) draw(6.0);
  }
  if('ResizeObserver' in window){ new ResizeObserver(resize).observe(stage); }
  else { window.addEventListener('resize', resize); }
  resize();

  if(!reduce){
    stage.addEventListener('pointermove', function(e){
      var r = stage.getBoundingClientRect();
      ndc.x =  ((e.clientX - r.left) / r.width)  * 2.0 - 1.0;
      ndc.y = -(((e.clientY - r.top) / r.height) * 2.0 - 1.0);
      ray.setFromCamera(ndc, camera);
      if(ray.ray.intersectPlane(seaPlane, hit)){ mesh.worldToLocal(hit); target.set(hit.x, hit.y); targetStr = 1.0; }
    });
    stage.addEventListener('pointerleave', function(){ targetStr = 0.0; });
  }

  if(reduce){ draw(6.0); return; }                             // reduced motion: ONE calm frame, no loop

  var elapsed = 0, last = performance.now(), raf = 0;
  function loop(now){
    var dt = (now - last) / 1000; last = now; if(dt > 0.1) dt = 0.1;
    elapsed += dt; draw(elapsed); raf = requestAnimationFrame(loop);
  }
  raf = requestAnimationFrame(loop);

  if('IntersectionObserver' in window){                        // pause when scrolled off-screen
    new IntersectionObserver(function(es){ es.forEach(function(en){
      if(en.isIntersecting){ if(!raf){ last = performance.now(); raf = requestAnimationFrame(loop); } }
      else if(raf){ cancelAnimationFrame(raf); raf = 0; }
    }); }, { threshold: 0.01 }).observe(stage);
  }
})();
```

## How it works

- **Vertex displacement.** `waveSum()` adds four directional sine waves of different direction, frequency and speed (a cheap Gerstner-style approximation). Each vertex's local `z` is raised by that sum, so the *geometry* genuinely undulates — this is what separates a wave shader from a flat fragment trick.
- **Exact normals.** Lighting needs a normal per vertex. Instead of guessing, the shader re-samples the **same** `waveSum` field a hair to the left/right/up/down (central differences) and builds the normal from the slope. Because height and normal come from one function, glints sit exactly on the crests.
- **Iridescent fragment shading.** A height gradient (`uDeep`→`uShallow`) gives the base water colour; a **fresnel** term (`pow(1 - N·V, 3.5)`) brightens grazing angles; an Inigo-Quilez **cosine palette** driven by fresnel + height + time paints the oily thin-film rainbow; a tight Blinn-Phong **specular** adds the sun glint.
- **Soft horizon.** `vFog = smoothstep(uFogNear, uFogFar, -mv.z)` fades the distant surface into `uSky`, which matches the CSS sky gradient — so the WebGL water and the CSS atmosphere meet seamlessly with no hard plane edge.
- **Pointer swell.** The pointer is ray-cast onto the math plane `y=0`, converted to the mesh's local space, and fed in as `uMouse`; `waveSum` adds a localised, exponentially-decaying ripple there.

## Customization

- **Sea state:** scale the four amplitudes (`0.300 … 0.055`) up for storm, down for a calm mirror. Raise the frequencies (`1.30 … 5.30`) for choppier chop.
- **Palette:** swap `uDeep` / `uShallow` / `uSky` for any mood (midnight blues, sunset golds, mint). Push `uIrid` and the `* fres * 0.55` mix for more/less oil-slick sheen.
- **Detail vs. cost:** `PlaneGeometry(18,18,256,256)` is the tessellation — drop to `160,160` for low-power devices, raise for huge displays.
- **Camera / horizon:** move `camera.position` / `lookAt` for a higher map view or a low skim; tune `uFogNear`/`uFogFar` so the horizon sits where you want it.
- **Glint sharpness:** the specular exponent `90.0` controls glint size (higher = tighter, more sun-like).

## Accessibility & performance

- **Reduced motion:** if `prefers-reduced-motion: reduce`, the script renders **one** static frame (`draw(6.0)`) and never starts the RAF loop — a calm, still water photo.
- **Off-screen pause:** an `IntersectionObserver` cancels the animation frame when the stage scrolls out of view, so it costs nothing when unseen.
- **GPU budget:** `setPixelRatio(min(dpr, 2))` caps retina overdraw; the vertex shader runs `waveSum` five times per vertex (height + four difference taps), so keep the segment count sane on mobile.
- **Contrast:** the water is bright; the `.ws-scrim` gradient darkens the corner under the headline so light UI text stays legible (aim for ≥ 4.5:1).
- **Decorative:** the canvas is `aria-hidden="true"` — it carries no information a screen reader needs.

## Gotchas

- **Don't redeclare three.js built-ins.** `position`, `uv`, `modelMatrix`, `viewMatrix`, `projectionMatrix`, `cameraPosition` and the float `precision` are injected for a `ShaderMaterial`; declaring them again is a compile error.
- **Transparent clear, or no sky.** Use `alpha:true` + `setClearColor(0x000000, 0)` and keep the canvas background transparent; otherwise the canvas paints over the CSS sky and you lose the horizon (and the fallback).
- **Match `uSky` to the CSS horizon colour.** If they differ, a visible seam appears where the water fog meets the gradient.
- **Two fallbacks are mandatory.** Guard `typeof THREE === 'undefined'` (CDN blocked) *and* wrap renderer/compile in `try/catch` (no WebGL) — both call `bail()` to reveal the CSS "sea".
- **Normals from displacement, not the mesh.** The geometry's stored normals are flat (all `+Z`); you must recompute them in the shader or the surface looks plastic and unlit.
- **`mat3(modelMatrix)` is fine** for the normal because the plane is only rotated/translated (no non-uniform scale). If you scale the plane unevenly, switch to the inverse-transpose (three.js exposes `normalMatrix`).
