Liquid text
Type that reacts to the pointer is the front page of Awwwards, and the reason so many builds of it are quietly bad is that they throw the text away. If the heading only exists as pixels in a canvas, you have no <h1>, nothing for a screen reader, nothing for search, and nothing at all on a machine with WebGL disabled. The build here keeps the real heading in the document permanently and only visually replaces it once a context has actually started. Two other decisions do the heavy lifting. The font is read off the hidden heading's computed style, so the canvas can never disagree with your stylesheet about what the typeface is. And the texture is white on black, which makes the red channel the mask — no alpha, no premultiplication, no fringe you cannot account for.
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
quickTo · ScrollTrigger · gsap.ticker · raw WebGL
Build time
A day. The text rasterisation is most of it.
No-code?
No. This is an HTML Embed with a canvas and a script. Webflow hosts it happily and knows nothing about it.
Webflow structure
element · class · attribute<b>Section</b> <i>type_stage</i> ← position: relative, height 100svh <b>HTML Embed</b> ← contains exactly: <canvas></canvas> <b>Heading H1</b> <i>type_heading</i> ← the REAL heading. Always present. <b>Div</b> <i>type_hud</i> <i>The script sets</i> <u>data-gl="on"</u> <i>on the stage after a context starts. Your CSS uses that to clip the heading to a pixel. Nothing is ever removed.</i>
Styles to set in the Designer
type_stage | <code>position: relative</code> and a real height (<code>100svh</code>). The canvas fills it, so the stage owns the dimensions. type_stage canvas | <code>display: block; width: 100%; height: 100%</code>. CSS size only — the script sets <code>canvas.width/height</code> to CSS size × DPR, capped at 2. type_heading | Styled normally: real font, real size, absolutely positioned and centred over the canvas. This is what a no-WebGL visitor sees, so it has to look right on its own. [data-gl="on"] .type_heading | <code>width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%)</code>. Visually gone, still in the accessibility tree. Never <code>display: none</code>, never <code>visibility: hidden</code> — both remove it from screen readers.
Building it with the Interactions panel
no code, free planWhat 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.
Custom attributes
Element settings → Custom attributestype_stage | data-gl | on | Written by the script only after <code>getContext("webgl")</code> succeeds. Your CSS hides the heading off this and nothing else.
CSS
Page settings → Inside <head> tagThe script
Page settings → Before </body> tag<!-- The GLSL, then the script. Page settings → Before </body> tag. --> <script id="frag" type="x-shader/x-fragment"> precision mediump float; varying vec2 vUv; uniform sampler2D uTex; uniform vec2 uPointer, uRes; uniform float uHover, uVel, uTime; uniform vec3 uFg, uBg; void main() { vec2 uv = vUv; // Aspect-correct, or the ripple is an ellipse on a wide canvas. vec2 p = uv - uPointer; p.x *= uRes.x / uRes.y; float d = length(p); float ring = smoothstep(0.36, 0.0, d); uv += normalize(p + 1e-5) * sin(d * 26.0 - uTime * 3.2) * 0.014 * ring * uHover; uv.x += sin(uv.y * 3.14159) * uVel * 0.045; // scroll shear float shift = 0.005 * ring * uHover + abs(uVel) * 0.006; // White text on black: the red channel IS the mask. No alpha anywhere. float mr = texture2D(uTex, uv + vec2(shift, 0.0)).r; float mg = texture2D(uTex, uv).r; float mb = texture2D(uTex, uv - vec2(shift, 0.0)).r; vec3 col = uBg; col.r = mix(uBg.r, uFg.r, mr); col.g = mix(uBg.g, uFg.g, mg); col.b = mix(uBg.b, uFg.b, mb); gl_FragColor = vec4(col, 1.0); } </script> <script> document.addEventListener("DOMContentLoaded", () => { gsap.registerPlugin(ScrollTrigger); if (matchMedia("(prefers-reduced-motion: reduce)").matches) return; const stage = document.querySelector(".type_stage"); const canvas = stage.querySelector("canvas"); const source = stage.querySelector(".type_heading"); const LINES = source.textContent.trim().split(" ").length > 2 ? ["Anvil", "Type Foundry"] : [source.textContent.trim()]; const gl = canvas.getContext("webgl", { antialias: false, alpha: false }); if (!gl) return; // heading stays visible. Done. /* … compile, link, full-screen triangle, uniforms — see the WebGL distortion lesson; the boilerplate is identical … */ // ── rasterise the type ──────────────────────────────────────────────── const text = document.createElement("canvas"); const tctx = text.getContext("2d"); function drawText() { const dpr = Math.min(devicePixelRatio || 1, 2); const w = text.width = Math.max(1, Math.round(canvas.clientWidth * dpr)); const h = text.height = Math.max(1, Math.round(canvas.clientHeight * dpr)); tctx.fillStyle = "#000"; tctx.fillRect(0, 0, w, h); // Read the font off the hidden heading. The canvas then cannot disagree // with the stylesheet about family, weight or style — ever. const cs = getComputedStyle(source); const REF = 100; // measure, then scale tctx.font = cs.fontStyle + " " + cs.fontWeight + " " + REF + "px " + cs.fontFamily; const widest = Math.max(...LINES.map(l => tctx.measureText(l).width)); const lineH = 0.94; const size = Math.min((w * 0.9 / widest) * REF, (h * 0.86) / (LINES.length * lineH)); tctx.font = cs.fontStyle + " " + cs.fontWeight + " " + size + "px " + cs.fontFamily; tctx.fillStyle = "#fff"; tctx.textAlign = "center"; tctx.textBaseline = "middle"; const block = LINES.length * size * lineH; LINES.forEach((l, i) => tctx.fillText(l, w / 2, h / 2 - block / 2 + size * lineH * (i + 0.5))); gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, text); } // Fonts FIRST. Rasterise before the webfont lands and the canvas shows // the fallback typeface for the rest of the session. document.fonts.ready.then(() => { resize(); // sets viewport + uRes, then calls drawText stage.dataset.gl = "on"; // only NOW is the heading clipped away const p = { x: 0.5, y: 0.5, hover: 0, vel: 0 }; const toX = gsap.quickTo(p, "x", { duration: 0.5, ease: "power3" }); const toY = gsap.quickTo(p, "y", { duration: 0.5, ease: "power3" }); const toH = gsap.quickTo(p, "hover", { duration: 0.6, ease: "power2" }); const toV = gsap.quickTo(p, "vel", { duration: 0.6, ease: "power3.out" }); stage.addEventListener("mousemove", e => { const r = stage.getBoundingClientRect(); toX((e.clientX - r.left) / r.width); toY(1 - (e.clientY - r.top) / r.height); // WebGL's Y runs upward toH(1); }); stage.addEventListener("mouseleave", () => toH(0)); ScrollTrigger.create({ onUpdate: self => toV(gsap.utils.clamp(-1, 1, self.getVelocity() / 2600)) }); // One ticker. GSAP owns every value; the shader only draws. gsap.ticker.add(time => { gl.uniform2f(uPointer, p.x, p.y); gl.uniform1f(uHover, p.hover); gl.uniform1f(uVel, p.vel); gl.uniform1f(uTime, time); gl.drawArrays(gl.TRIANGLES, 0, 3); }); }); new ResizeObserver(() => { resize(); }).observe(canvas); }); </script>
Gotchas that will cost you an hour
- Never delete the heading. Keep the real <h1> and clip it with clip-path: inset(50%). display: none and visibility: hidden both remove it from the accessibility tree, so you have shipped a hero with no heading at all.
- Only hide it once a context exists. Gate the CSS on data-gl="on", written after getContext returns. Hide it up front and a visitor without WebGL gets an empty black rectangle.
- Rasterise after document.fonts.ready. Draw before the webfont arrives and the canvas is stuck with the fallback typeface for the whole session — with no visible error and no way to notice on a fast connection.
- Read the font from getComputedStyle. Hard-coding the family in the canvas call means the day someone changes the heading in the Designer, the hero silently renders in the old typeface.
- White on black, and sample .r. Transparent text drags you into premultiplied-alpha behaviour that differs between canvas and WebGL, and you will spend an afternoon on a halo you cannot explain.
- Redraw the texture on resize, not per frame. Rasterising text is expensive. It belongs in the ResizeObserver and the font callback, nowhere else.
- Cap DPR at 2. A 3× phone would rasterise nine times the pixels for a difference nobody can see, twice — once for the text canvas and once for the drawing buffer.
Push it further
- Add a second texture with the same text in a different weight and blend between them by pointer distance — the type appears to thicken under the cursor.
- Replace the sine ripple with a scrolling noise field for a slow liquid drift even when the pointer is still. Keep the amplitude tiny or it reads as a broken font.
- Feed the pointer uniform from the cursor system's smoothed position so the ripple and the cursor share one source of truth.
Webflow Motion Lab — GSAP 3.15, all plugins free.
30
/ 30