---
name: webgl-distortion
description: Use when you want "Advanced, experimental, premium" - Uses GPU shaders to warp images, scenes, or transitions.
---

# WebGL Distortion

> **Category:** Advanced WebGL / Canvas  -  **Personality:** Advanced, experimental, premium
>
> **Best use:** Creative sites, immersive pages
>
> **Ratings:** Professional 3/5 - Casual 3/5 - Exotic 5/5 - Visual impact 5/5 - Difficulty 5/5 - Performance cost 5/5

## What it is

WebGL Distortion renders an image onto a GPU surface and runs a **GLSL fragment shader** that offsets each pixel's texture lookup, so the picture appears to ripple, melt, or refract in real time. Here a single full-screen quad shows a coastline; an ambient wave warps the whole frame while a **pointer-driven ripple + chromatic aberration** intensify wherever the cursor goes. It is the look behind immersive agency sites and "liquid" hero films — high impact, but it costs a WebGL context and a per-frame draw, so reserve it for a marquee moment, not body content.

## Dependencies / CDN

[three.js](https://threejs.org/) r160, pinned, with Subresource Integrity:

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

Display fonts are optional flavour (`Instrument Serif`, `Hanken Grotesk`, `JetBrains Mono`).

## HTML

The `<img>` is both the texture three.js loads **and** the no-WebGL fallback. The canvas is created by JS and layered on top.

```html
<div class="stage" id="stage">
  <img class="img" src="/assets/img/landscape-coast.jpg" alt="Coastline" draggable="false">
  <div class="veil"></div>            <!-- scrim, shown only in fallback -->
  <div class="ui"> ...headline / CTA / HUD... </div>
  <!-- <canvas> is injected here by the script -->
</div>
```

## CSS

```css
.stage{position:relative;overflow:hidden;border-radius:26px;isolation:isolate;
  height:clamp(520px,72vh,700px);background:#05080f;cursor:crosshair}
.img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;z-index:0}
.canvas{position:absolute;inset:0;width:100%!important;height:100%!important;
  z-index:1;opacity:0;transition:opacity .55s}      /* crossfade in once ready */
.canvas.on{opacity:1}
/* fallback only: dark scrim + slow drift when the shader can't run */
.veil{position:absolute;inset:0;z-index:2;opacity:0;transition:opacity .4s;pointer-events:none;
  background:linear-gradient(102deg,rgba(5,8,15,.9),rgba(5,8,15,.12) 70%,rgba(5,8,15,.4))}
.stage.noctx .veil{opacity:1}
.stage.noctx .img{animation:drift 26s ease-in-out infinite alternate}
@keyframes drift{from{transform:scale(1.06)}to{transform:scale(1.13) translate3d(1.4%,1%,0)}}
.ui{position:absolute;inset:0;z-index:4;pointer-events:none}   /* re-enable on buttons */
.ui button,.ui a{pointer-events:auto}
```

## JavaScript

A full-screen quad (`PlaneGeometry(2,2)`) with a clip-space vertex shader, plus the warp in the fragment shader. The loop is gated on `prefers-reduced-motion` and an `IntersectionObserver`.

```js
var VERT = 'varying vec2 vUv;void main(){vUv=uv;gl_Position=vec4(position.xy,0.,1.);}';
var FRAG = `
precision highp float; varying vec2 vUv;
uniform sampler2D uTex; uniform vec2 uRes,uImg,uMouse; uniform float uTime,uHover;
vec2 cover(vec2 uv){ float sa=uRes.x/uRes.y, ia=uImg.x/uImg.y;          // background-size:cover
  vec2 s=(sa>ia)?vec2(1.,ia/sa):vec2(sa/ia,1.); return (uv-.5)*s+.5; }
void main(){
  vec2 uv=cover(vUv); float t=uTime;
  float d=distance(vUv,uMouse);
  float peak=smoothstep(0.46,0.0,d)*uHover;                            // strong near cursor
  vec2 flow=vec2(sin(vUv.y*9.+t*.9),cos(vUv.x*11.+t*.8))*0.0035;        // ambient
  vec2 dir=normalize(vUv-uMouse+1e-5);
  vec2 off=flow + dir*sin(d*52.-t*5.)*peak*0.026 + dir*peak*0.02*sin(t*1.8);
  float ca=0.0008+peak*0.006;                                          // chromatic aberration
  vec3 col; col.r=texture2D(uTex,uv+off+dir*ca).r;
            col.g=texture2D(uTex,uv+off).g;
            col.b=texture2D(uTex,uv+off-dir*ca).b;
  col*=mix(0.55,1.0,smoothstep(1.18,0.25,distance(vUv,vec2(.5))));      // vignette
  gl_FragColor=vec4(col,1.0);
}`;

var stage=document.getElementById('stage');
var reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
function webglOK(){try{var c=document.createElement('canvas');
  return !!(window.WebGLRenderingContext&&(c.getContext('webgl')||c.getContext('experimental-webgl')));}catch(e){return false;}}

if(typeof THREE==='undefined'||!webglOK()){ stage.classList.add('noctx'); }   // -> CSS fallback
else {
  var W=stage.clientWidth,H=stage.clientHeight;
  var renderer=new THREE.WebGLRenderer({antialias:true});
  renderer.setPixelRatio(Math.min(devicePixelRatio||1,2));
  renderer.setSize(W,H,false);
  if(THREE.LinearSRGBColorSpace) renderer.outputColorSpace=THREE.LinearSRGBColorSpace;
  var canvas=renderer.domElement; canvas.className='canvas'; stage.appendChild(canvas);

  var scene=new THREE.Scene(), camera=new THREE.Camera();
  var u={uTex:{value:null},uRes:{value:new THREE.Vector2(W,H)},uImg:{value:new THREE.Vector2(1536,1024)},
         uTime:{value:0},uMouse:{value:new THREE.Vector2(.5,.5)},uHover:{value:0}};
  var mesh=new THREE.Mesh(new THREE.PlaneGeometry(2,2),
    new THREE.ShaderMaterial({uniforms:u,vertexShader:VERT,fragmentShader:FRAG}));
  mesh.frustumCulled=false; scene.add(mesh);                            // bare camera has no frustum

  new THREE.TextureLoader().load('/assets/img/landscape-coast.jpg',function(t){
    t.minFilter=t.magFilter=THREE.LinearFilter; t.generateMipmaps=false;
    u.uImg.value.set(t.image.width,t.image.height); u.uTex.value=t;
    renderer.render(scene,camera); canvas.classList.add('on');
  });

  var tmx=.5,tmy=.5,tHover=0;
  function move(e){var r=stage.getBoundingClientRect();
    tmx=(e.clientX-r.left)/r.width; tmy=1-(e.clientY-r.top)/r.height; tHover=1;}
  if(!reduced){ stage.addEventListener('pointermove',move);
    stage.addEventListener('pointerleave',function(){tHover=0;}); }

  if(reduced){ u.uMouse.value.set(.5,.46); u.uHover.value=.65; u.uTime.value=1.7; } // ONE static frame
  else {
    var raf,last=0,tsec=0;
    (function frame(now){ raf=requestAnimationFrame(frame);
      var dt=Math.min(.05,(now-last)/1000); last=now; tsec+=dt;
      u.uHover.value+=(tHover-u.uHover.value)*.06;
      u.uMouse.value.x+=(tmx-u.uMouse.value.x)*.1; u.uMouse.value.y+=(tmy-u.uMouse.value.y)*.1;
      u.uTime.value=tsec; renderer.render(scene,camera);
    })(0);
  }
}
```

## How it works

- **One full-screen quad.** A `PlaneGeometry(2,2)` with the vertex shader writing `gl_Position = vec4(position.xy,0,1)` covers the whole clip space, so no camera matrices are needed — every fragment maps 1:1 to a screen pixel.
- **The warp is a UV offset.** Instead of moving geometry, the fragment shader samples the texture at `uv + offset`. `offset` is an ambient sine flow plus a `dir * sin(distance*k - time)` ripple radiating from the pointer, scaled by `peak` (a `smoothstep` falloff around `uMouse`), so distortion is strongest under the cursor and calm elsewhere.
- **Chromatic aberration** samples R/G/B at slightly different offsets along the pointer direction — the "premium lens" shimmer that grows with the warp.
- **`cover()`** replicates CSS `background-size:cover` in UV space using the canvas and image aspect ratios, so the photo fills the panel without stretching on any viewport.

## Customization

- **Texture:** swap the `<img src>` and the `TextureLoader` URL (keep them identical — same file is the fallback). `uImg` is read from the loaded image automatically.
- **Warp strength / reach:** the `peak*0.026` ripple amplitude and the `smoothstep(0.46,0.0,d)` radius. Larger radius = wider influence; bigger amplitude = more melt.
- **Speed:** the `t*5.0` ripple and `t*0.9` ambient terms.
- **Mood:** the final `col*=` vignette/grade lines; add a tint by multiplying `col` by an RGB constant.
- **Feel of the follow:** the `0.06` (hover) and `0.1` (mouse) easing factors — lower = laggier/dreamier.

## Accessibility & performance

- **Reduced motion:** when `prefers-reduced-motion: reduce`, the loop never starts; the shader renders **one baked static frame** (a fixed centred warp) and pointer handlers are skipped.
- **Two-layer fallback:** if three.js fails to load (CDN/SRI) or `webglOK()` is false, the script adds `.noctx` and the plain `<img>` (with a CSS scrim + slow drift) carries the page. A `webglcontextlost` listener and a texture-error handler fall back the same way.
- **Frame budget:** an `IntersectionObserver` pauses the RAF loop when the stage scrolls off-screen, and `visibilitychange` pauses it on hidden tabs. Pixel ratio is capped at 2 so retina phones don't render 9× the pixels.
- Decorative layers carry `aria-hidden`; the headline/CTA live in normal DOM and stay readable over the dark left scrim baked into the shader.

## Gotchas

- **Pin the version + SRI.** A floating `three@latest` will eventually break the API *and* invalidate any cached integrity hash. Recompute the hash if you bump the version: `curl -fsSL <url> | openssl dgst -sha384 -binary | openssl base64 -A`.
- **`frustumCulled = false`** — a bare `THREE.Camera()` has no real frustum, so the quad can be culled and the canvas renders black until you disable culling.
- **Texture flip:** `TextureLoader` sets `flipY` by default, so sampling with `vUv` (origin bottom-left) shows the image upright; convert pointer Y the same way (`1 - y`).
- **Color management:** three r152+ defaults `outputColorSpace` to sRGB, which brightens a hand-graded shader. Set `LinearSRGBColorSpace` for a 1:1 passthrough (or grade in linear space deliberately).
- **Don't size the canvas with CSS alone.** Drive `renderer.setSize()` from the element's measured box (here via `ResizeObserver`) or the drawing buffer stretches and the `cover()` aspect math drifts.
