Building a Fluted Glass WebGL Shader for Webflow.com

Most WebGL effects are built for one spot on one page. You scope them tight, you ship them, done. This one was different from the start: a fluted glass component that could run anywhere on webflow.com, as many instances as needed, each one independently configured. No shader edits. No hardcoded values. Just drop it in and configure from data attributes.

That's a different problem than building a shader. That's building a system.

The visual design came from Metalab. I was brought in to make it real in WebGL. The final component supports configurable column count, distortion strength, grain, shape types, background images via URL or CMS field, colors via hex or Webflow CSS variables, hover interactivity, and light/dark mode — all from data attributes on the element.

The effect itself is vertical glass columns with animated color shapes behind the surface. The design was intentionally flatter than real fluted glass. A physically correct version would have a specular highlight riding the peak of each column, but the brief called for something closer to etched glass: distortion and column structure present, without the cylindrical lens quality. That distinction shaped several decisions in the shader.

Stack

Three.js, a custom ShaderMaterial on a PlaneGeometry. The flat quad covers the container element and all the glass logic lives in the fragment shader. Shape positions, colors, and column parameters are passed as uniforms, read at init from data attributes.

Column Lookup Texture

Running one shader instance is easy. Running twenty on the same page — all animating, all responding to mouse position, while keeping PageSpeed scores healthy — requires being deliberate about what gets computed where and when.

The column layout is the first thing to get right. Each instance has its own column count, width variation and seed, all set from data attributes. Rather than recalculating column boundaries inside the shader on every frame, the boundaries are computed once in JavaScript at init and baked into a small texture. From that point on, every pixel does a single texture lookup to find its column index:

vec4 d = texture2D(u_column_lookup, vec2(vUv.x, 0.0));
float columnIndex = d.r * 255.0;

No per-frame recalculation. No per-instance recalculation. One lookup per pixel.

[ ASSET_002 — Diagram ]

Distortion

Each column gets a unique lateral UV offset, shifting what's visible behind it left or right. That's the refraction. A GPU hash function turns the column index into a stable per-column float, scaled by data-distortion:

float s = sin(columnIndex * 12.99) * 43758.5;
float offset = (fract(s) - 0.5) * u_distortion;
vec2 distortedUV = vec2(vUv.x + offset, vUv.y);

All subsequent shape sampling runs against this distorted UV rather than the original screen position.

Shapes

The color elements behind the glass are called blobs in the code (noiseBlob, u_blob1_pos) — a name that stuck from the early version before stars, rectangles and triangles were added as shape options. Each has a smooth gradient falloff from center to edge. All four types dispatch through one function, switchable via data attributes on the element.

Motion

Each shape lerps toward a target position computed from overlapping sine waves. The staggered lerp rates are what make the motion feel like material rather than UI:

blobs.b1.lerp(defaultTargets.b1, 0.025); // leads
blobs.b2.lerp(defaultTargets.b2, 0.022); // follows
blobs.b3.lerp(defaultTargets.b3, 0.018); // trails

Three layers, three slightly different rates. On hover each shape picks up an additional offset from the cursor position, with different sensitivities and opposing directions across the layers. Parallax depth without any 3D geometry — just differential timing.

[ ASSET_003 — Interactive: staggered lerp animation ]

[ ASSET_004 — Interactive: hover parallax ]

Background Images and Safari

Background images are blurred on the CPU at init so the shader never has to blur per frame. Most browsers handle it with ctx.filter = 'blur(15px)'. Safari doesn't support that, so the fallback downsizes to 32×32 and upscales to 512×512, using bilinear interpolation during the upscale to produce the blur at zero per-frame cost:

if (typeof blurCtx.filter === 'string') {
 blurCtx.filter = 'blur(15px)';
 blurCtx.drawImage(img, 0, 0, 512, 512);
} else {
 smallCtx.drawImage(img, 0, 0, 32, 32);
 blurCtx.drawImage(smallCanvas, 0, 0, 512, 512);
}

The initial fallback was written for iOS Safari. On launch day macOS Safari turned out to need the same treatment, so Windows Safari got covered at the same time.

Performance

Mouse handling follows the same principle as the lookup texture: compute once, share everywhere. The first version registered a separate mousemove listener per instance. The final version uses one shared handler, with hover state per instance determined by a bounds check.

Motion vectors were allocating new THREE.Vector2() for parallax targets on every frame. At 30fps across many instances that generates enough short-lived objects for the garbage collector to cause visible stutters. Fix: allocate once upfront, update in place with .set().

Beyond that: instances skip rendering until their fade-in begins, an IntersectionObserver removes off-screen instances from the active render set, and the rAF loop checks document.hidden and pauses when the tab is backgrounded.

One edge case worth calling out: PageSpeed's mobile emulation runs on SwiftShader, a CPU-based software renderer with no real GPU. Shader compilation in software is slow enough to tank Lighthouse scores even though no real user would ever hit it. The component detects this via WEBGL_debug_renderer_info at init and exits early, keeping scores clean without affecting anyone on real hardware.

The Slider Variant

After shipping the core component, I built a version where the shader acts as a live animated background for a custom image stack slider. As the user cycles through slides, the active image gets passed into the shader's background uniform and runs through the same CPU blur pipeline. The glass surface updates to a refracted, animated version of whatever slide is active.

The slider itself is a custom GSAP build, which probably deserves its own post.

[ ASSET_005 — Video ]

Summary

Building this as a system rather than a one-off shaped every decision. The lookup texture, the shared mouse handler: none of that comes up when something runs once. It only matters when the same component has to perform across an entire site, in every browser, at any number of instances.

The visual result is much quieter than most WebGL work. No full-screen takeover, no dramatic entrance animation. Just glass, light, and motion sitting inside a page layout. That constraint ended up being the most interesting part of the problem.

See It Live

It's live on webflow.com now. Same component, same shader, running in production.

[View it on webflow.com →]

Articles
Articles

June 9, 2026

Building a Fluted Glass WebGL Shader for Webflow.com

A technical deep dive into building a fluted glass WebGL shader for Webflow.com, covering the refraction effect, performance, and implementation.

Table of contents
00 Min read

Summary

We built webflow.com (case study here), and we went deep on all the details.

This is a deep dive into how we recreated the look of fluted glass in the browser with a custom WebGL shader component, including the refraction, the performance trade-offs, and how we shipped it on Webflow.

Building a Fluted Glass WebGL Shader for Webflow.com

Most WebGL effects are built for one spot on one page. You scope them tight, you ship them, done. This one was different from the start: a fluted glass component that could run anywhere on webflow.com, as many instances as needed, each one independently configured. No shader edits. No hardcoded values. Just drop it in and configure from data attributes.

That's a different problem than building a shader. That's building a system.

The visual design came from Metalab. I was brought in to make it real in WebGL. The final component supports configurable column count, distortion strength, grain, shape types, background images via URL or CMS field, colors via hex or Webflow CSS variables, hover interactivity, and light/dark mode — all from data attributes on the element.

The effect itself is vertical glass columns with animated color shapes behind the surface. The design was intentionally flatter than real fluted glass. A physically correct version would have a specular highlight riding the peak of each column, but the brief called for something closer to etched glass: distortion and column structure present, without the cylindrical lens quality. That distinction shaped several decisions in the shader.

Stack

Three.js, a custom ShaderMaterial on a PlaneGeometry. The flat quad covers the container element and all the glass logic lives in the fragment shader. Shape positions, colors, and column parameters are passed as uniforms, read at init from data attributes.

Column Lookup Texture

Running one shader instance is easy. Running twenty on the same page — all animating, all responding to mouse position, while keeping PageSpeed scores healthy — requires being deliberate about what gets computed where and when.

The column layout is the first thing to get right. Each instance has its own column count, width variation and seed, all set from data attributes. Rather than recalculating column boundaries inside the shader on every frame, the boundaries are computed once in JavaScript at init and baked into a small texture. From that point on, every pixel does a single texture lookup to find its column index:

vec4 d = texture2D(u_column_lookup, vec2(vUv.x, 0.0));
float columnIndex = d.r * 255.0;

No per-frame recalculation. No per-instance recalculation. One lookup per pixel.

[ ASSET_002 — Diagram ]

Distortion

Each column gets a unique lateral UV offset, shifting what's visible behind it left or right. That's the refraction. A GPU hash function turns the column index into a stable per-column float, scaled by data-distortion:

float s = sin(columnIndex * 12.99) * 43758.5;
float offset = (fract(s) - 0.5) * u_distortion;
vec2 distortedUV = vec2(vUv.x + offset, vUv.y);

All subsequent shape sampling runs against this distorted UV rather than the original screen position.

Shapes

The color elements behind the glass are called blobs in the code (noiseBlob, u_blob1_pos) — a name that stuck from the early version before stars, rectangles and triangles were added as shape options. Each has a smooth gradient falloff from center to edge. All four types dispatch through one function, switchable via data attributes on the element.

Motion

Each shape lerps toward a target position computed from overlapping sine waves. The staggered lerp rates are what make the motion feel like material rather than UI:

blobs.b1.lerp(defaultTargets.b1, 0.025); // leads
blobs.b2.lerp(defaultTargets.b2, 0.022); // follows
blobs.b3.lerp(defaultTargets.b3, 0.018); // trails

Three layers, three slightly different rates. On hover each shape picks up an additional offset from the cursor position, with different sensitivities and opposing directions across the layers. Parallax depth without any 3D geometry — just differential timing.

[ ASSET_003 — Interactive: staggered lerp animation ]

[ ASSET_004 — Interactive: hover parallax ]

Background Images and Safari

Background images are blurred on the CPU at init so the shader never has to blur per frame. Most browsers handle it with ctx.filter = 'blur(15px)'. Safari doesn't support that, so the fallback downsizes to 32×32 and upscales to 512×512, using bilinear interpolation during the upscale to produce the blur at zero per-frame cost:

if (typeof blurCtx.filter === 'string') {
 blurCtx.filter = 'blur(15px)';
 blurCtx.drawImage(img, 0, 0, 512, 512);
} else {
 smallCtx.drawImage(img, 0, 0, 32, 32);
 blurCtx.drawImage(smallCanvas, 0, 0, 512, 512);
}

The initial fallback was written for iOS Safari. On launch day macOS Safari turned out to need the same treatment, so Windows Safari got covered at the same time.

Performance

Mouse handling follows the same principle as the lookup texture: compute once, share everywhere. The first version registered a separate mousemove listener per instance. The final version uses one shared handler, with hover state per instance determined by a bounds check.

Motion vectors were allocating new THREE.Vector2() for parallax targets on every frame. At 30fps across many instances that generates enough short-lived objects for the garbage collector to cause visible stutters. Fix: allocate once upfront, update in place with .set().

Beyond that: instances skip rendering until their fade-in begins, an IntersectionObserver removes off-screen instances from the active render set, and the rAF loop checks document.hidden and pauses when the tab is backgrounded.

One edge case worth calling out: PageSpeed's mobile emulation runs on SwiftShader, a CPU-based software renderer with no real GPU. Shader compilation in software is slow enough to tank Lighthouse scores even though no real user would ever hit it. The component detects this via WEBGL_debug_renderer_info at init and exits early, keeping scores clean without affecting anyone on real hardware.

The Slider Variant

After shipping the core component, I built a version where the shader acts as a live animated background for a custom image stack slider. As the user cycles through slides, the active image gets passed into the shader's background uniform and runs through the same CPU blur pipeline. The glass surface updates to a refracted, animated version of whatever slide is active.

The slider itself is a custom GSAP build, which probably deserves its own post.

[ ASSET_005 — Video ]

Summary

Building this as a system rather than a one-off shaped every decision. The lookup texture, the shared mouse handler: none of that comes up when something runs once. It only matters when the same component has to perform across an entire site, in every browser, at any number of instances.

The visual result is much quieter than most WebGL work. No full-screen takeover, no dramatic entrance animation. Just glass, light, and motion sitting inside a page layout. That constraint ended up being the most interesting part of the problem.

See It Live

It's live on webflow.com now. Same component, same shader, running in production.

[View it on webflow.com →]

Written by

Frida Wiig

Frida Wiig

Creative Developer

SHARE

https://www.n4.studio/blog/building-a-fluted-glass-component-for-webflow

Stay ahead with the latest Webflow research and trends.

blob 1 — leads (0.025)
blob 2 — follows (0.022)
blob 3 — trails (0.018)
blob 1 — tracks cursor
blob 2 — moves opposite
blob 3 — tracks, slow

move cursor over the canvas