L5

·

Shader · GPU

WebGL distortion panel

There is a ceiling on what CSS transforms can do, and this is what sits above it: per-pixel control. A crossfade treats an image as a rectangle; a shader treats it as a field you can push around, so a dissolve can have a shape and a scroll can physically bend the frame. The important architectural point is that GSAP still owns every value. The shader holds no state and makes no decisions — it reads two uniforms, uProgress (a hover value tweened by quickTo) and uVelocity (ScrollTrigger.getVelocity(), normalised and clamped), and draws. No three.js: for two textures and one quad, a renderer library is bigger than the thing it renders.

Open the live demo →

How to build

Script only

Free plan gets you part of the way

The panel covers some of this. The rest needs the script, which needs a paid plan — build the panel half now, come back for the rest.

How the two routes work →

Plugins

ScrollTrigger · quickTo · gsap.ticker · raw WebGL

Build time

A day, if you have not written GLSL before.

No-code?

No. This is an HTML Embed with a canvas and a script. Webflow hosts it happily; the Designer knows nothing about it.

01

Webflow structure

element · class · attribute

<b>Div</b> <i>gl_panel</i> <u>data-panel="hero" data-a="/img/a.jpg" data-b="/img/b.jpg"</u> <b>HTML Embed</b> ← contains exactly: &lt;canvas&gt;&lt;/canvas&gt; <b>Image</b> <i>gl_fallback</i> ← the real img, absolute inset 0, object-fit cover <b>Div</b> <i>gl_overlay</i> ← title + meta, pointer-events none <b>Heading H3</b> <b>Text</b> <i>The fallback is not a nicety.</i> It is what a visitor sees on a machine with no WebGL, on a locked-down corporate browser, and in the four seconds before the textures finish decoding.

02

Styles to set in the Designer

gl_panel | <code>position: relative; overflow: hidden;</code> and a fixed <code>aspect-ratio</code>. The canvas sizes itself to this box. gl_panel canvas | <code>display: block; width: 100%; height: 100%</code>. CSS size and drawing-buffer size are different things — the script sets <code>canvas.width/height</code> to CSS size × DPR. gl_fallback | <code>position: absolute; inset: 0; object-fit: cover</code>. The script sets <code>data-gl="on"</code> on the panel only after a successful init; your CSS hides the fallback off that attribute. gl_overlay | <code>pointer-events: none</code> and <code>mix-blend-mode: difference</code> on the type, so the caption stays legible while the image underneath is being distorted.

03

Building it with the Interactions panel

no code, free plan

What the panel still can’t do. Only custom code can close these gaps — there is no panel setting for any of them.

See “The script” below for the exact code.

04

Custom attributes

Element settings → Custom attributes

gl_panel | data-a / data-b | /img/….jpg | The two textures. Keep them the same aspect ratio or the shader's cover-fit will crop them differently and the dissolve will slide. gl_panel | data-gl | on | Written by the script after init succeeds. Your CSS uses it to hide the fallback — never hide the fallback in CSS by default.

05

CSS

Page settings → Inside <head> tag

Inside <head> tag

Copy

06

The script

Page settings → Before </body> tag

Before </body> tag

Copy

<!-- Page settings → Before </body> tag. The GLSL is in a script type="x-shader". --> <script id="frag" type="x-shader/x-fragment"> precision mediump float; varying vec2 vUv; uniform sampler2D uTex0, uTex1; uniform float uProgress, uVelocity, uTime; uniform vec2 uRes, uImg; // object-fit: cover, in a shader vec2 cover(vec2 uv, vec2 res, vec2 img) { float rs = res.x / res.y, ri = img.x / img.y; vec2 s = rs < ri ? vec2(ri / rs, 1.0) : vec2(1.0, rs / ri); return (uv - 0.5) * s + 0.5; } float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float noise(vec2 p){ vec2 i = floor(p), f = fract(p), u = f * f * (3.0 - 2.0 * f); return mix(mix(hash(i), hash(i + vec2(1,0)), u.x), mix(hash(i + vec2(0,1)), hash(i + vec2(1,1)), u.x), u.y); } void main() { vec2 uv = cover(vUv, uRes, uImg); float v = clamp(uVelocity, -1.0, 1.0); // scroll velocity bends the frame, strongest through the middle uv.x += sin(uv.y * 3.14159) * v * 0.055; uv.y += sin(uv.x * 6.2831 + uTime) * v * 0.018; // hover sweeps a threshold across noise — a dissolve with a SHAPE float n = noise(uv * 4.0 + uTime * 0.06); float grad = (uv.x + uv.y) * 0.5; float mask = smoothstep(uProgress - 0.18, uProgress + 0.18, mix(grad, n, 0.55)); float edge = 1.0 - abs(mask - 0.5) * 2.0; float shift = abs(v) * 0.008 + edge * uProgress * 0.006; // chromatic split vec3 a = vec3(texture2D(uTex0, uv + vec2(shift,0)).r, texture2D(uTex0, uv).g, texture2D(uTex0, uv - vec2(shift,0)).b); vec3 b = vec3(texture2D(uTex1, uv + vec2(shift,0)).r, texture2D(uTex1, uv).g, texture2D(uTex1, uv - vec2(shift,0)).b); gl_FragColor = vec4(mix(b, a, mask), 1.0); } </script> <script> document.addEventListener("DOMContentLoaded", async () => { gsap.registerPlugin(ScrollTrigger); if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; const VERT = "attribute vec2 aPos; varying vec2 vUv;" + "void main(){ vUv = aPos * 0.5 + 0.5; gl_Position = vec4(aPos, 0.0, 1.0); }"; const FRAG = document.getElementById("frag").textContent; // ONE velocity value, shared by every panel on the page. const vel = { v: 0 }; const setVel = gsap.quickTo(vel, "v", { duration: 0.6, ease: "power3.out" }); ScrollTrigger.create({ onUpdate: self => setVel(gsap.utils.clamp(-1, 1, self.getVelocity() / 2600)) }); const panels = []; for (const holder of document.querySelectorAll("[data-panel]")) { const canvas = holder.querySelector("canvas"); const gl = canvas.getContext("webgl", { antialias: false, alpha: false }); if (!gl) continue; // no WebGL: the fallback img stays const sh = (type, src) => { const s = gl.createShader(type); gl.shaderSource(s, src); gl.compileShader(s); if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) { console.error(gl.getShaderInfoLog(s)); return null; } return s; }; const prog = gl.createProgram(); gl.attachShader(prog, sh(gl.VERTEX_SHADER, VERT)); gl.attachShader(prog, sh(gl.FRAGMENT_SHADER, FRAG)); gl.linkProgram(prog); gl.useProgram(prog); // one full-screen triangle — cheaper than two, and no seam down the middle gl.bindBuffer(gl.ARRAY_BUFFER, gl.createBuffer()); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW); const loc = gl.getAttribLocation(prog, "aPos"); gl.enableVertexAttribArray(loc); gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0); // Rasterise to a 2D canvas first: works for SVG, avoids tainting, and // guarantees power-of-two-friendly dimensions. const load = src => new Promise(res => { const i = new Image(); i.crossOrigin = "anonymous"; i.onload = () => { const c = document.createElement("canvas"); c.width = c.height = 1024; c.getContext("2d").drawImage(i, 0, 0, 1024, 1024); res(c); }; i.src = src; }); const [ca, cb] = await Promise.all([load(holder.dataset.a), load(holder.dataset.b)]); gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1); [ca, cb].forEach((src, unit) => { gl.activeTexture(gl.TEXTURE0 + unit); gl.bindTexture(gl.TEXTURE_2D, gl.createTexture()); // CLAMP_TO_EDGE: the distortion pushes uv outside 0–1; tiling would show a seam 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); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR); gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, src); }); const u = n => gl.getUniformLocation(prog, n); gl.uniform1i(u("uTex0"), 0); gl.uniform1i(u("uTex1"), 1); gl.uniform2f(u("uImg"), 1024, 1024); const resize = () => { const dpr = Math.min(devicePixelRatio || 1, 2); // cap at 2 or you burn a phone's battery canvas.width = Math.round(canvas.clientWidth * dpr); canvas.height = Math.round(canvas.clientHeight * dpr); gl.viewport(0, 0, canvas.width, canvas.height); gl.uniform2f(u("uRes"), canvas.width, canvas.height); }; resize(); new ResizeObserver(resize).observe(canvas); // GSAP drives the hover value. The shader never decides anything. const p = { v: 0 }; const to = gsap.quickTo(p, "v", { duration: 0.9, ease: "power3.out" }); holder.addEventListener("mouseenter", () => to(1)); holder.addEventListener("mouseleave", () => to(0)); holder.dataset.gl = "on"; // now the fallback can hide panels.push({ gl, prog, u, p }); } // ONE ticker for every panel. Never one requestAnimationFrame per canvas. gsap.ticker.add(time => { for (const pan of panels) { pan.gl.useProgram(pan.prog); pan.gl.uniform1f(pan.u("uProgress"), pan.p.v); pan.gl.uniform1f(pan.u("uVelocity"), vel.v); pan.gl.uniform1f(pan.u("uTime"), time); pan.gl.drawArrays(pan.gl.TRIANGLES, 0, 3); } }); }); </script>

07

Gotchas that will cost you an hour

  1. The fallback image must be visible by default. Hide it only once data-gl="on" is set. Otherwise a browser with WebGL disabled — which includes plenty of corporate machines — shows your client a black rectangle.
  2. canvas.width is not canvas.style.width. The first is the drawing buffer, the second is CSS layout size. Set only the CSS one and you render at 300×150 and stretch it. Cap DPR at 2 or a phone renders four times the pixels for nothing.
  3. CLAMP_TO_EDGE on both axes. The distortion pushes uv outside 0–1 and the default REPEAT tiles the image, so a mirrored strip slides in from the edge exactly when someone scrolls hard.
  4. Cross-origin textures taint the context. Webflow serves assets from its own CDN, which is a different origin — set crossOrigin = "anonymous" before src, or the texture upload fails silently.
  5. One gsap.ticker, not one rAF per canvas. Three panels with three loops is three separate frame budgets fighting each other, and it will not stay at 60fps.
  6. Use gsap.ticker.time, not performance.now(), for uTime. Mixing clocks means the shader's animation and GSAP's tweens drift apart, and you will never work out why.
  7. Pause offscreen panels. A ScrollTrigger onToggle that skips the draw when the panel is out of view is four lines and it is the difference between a page that idles at 2% CPU and one that idles at 30%.
08

Push it further

  • Swap the procedural noise for a real displacement map texture — that is what most of the WebGL hover effects you have seen are actually doing, and it gives you art-directable transitions.
  • Feed pointer position in as a third uniform and warp the uv around the cursor. Combined with the cursor system it becomes one coherent interaction.
  • Render text to an offscreen canvas and use it as a texture — that is how the “liquid type” hero on half of the Awwwards front page is built.

Webflow Motion Lab — GSAP 3.15, all plugins free.

29

/ 30