Bridging the DOM and WebGL for Image Effects
Adding WebGL effects to existing images and videos on a page usually means rewriting the page so the browser that renders those elements are built entirely in WebGL. But <img> and <video> tags can stay in the DOM while a WebGL layer renders them with custom shader effects, keeping page markup and interactions intact.
The workflow breaks into four pieces: build a normal page, render chosen media with WebGL, write or find the shaders for the effect, and connect user interactions to shader uniforms. The connecting layer — JavaScript talking to GLSL uniforms — is where most of the synchronization work happens. CurtainsJS handles the heavy lifting of creating WebGL planes from DOM images and videos and keeping their positions in sync with the layout.
Setting Up a Slider in WebGL
Consider a draggable image slider where each slide is full-width, the drag has momentum, the release snaps to the nearest slide, and hovering applies a fisheye and color-inversion effect. GSAP provides the drag, inertia, and animation utilities, making the slider itself straightforward to build. Once it is built, the media can be ported to WebGL.
With CurtainsJS, converting an image or video into a WebGL plane takes minimal code:
// Create a new curtains instance
const curtains = new Curtains({ container: "canvas", autoRender: false });
// Use a single rAF for both GSAP and Curtains
function renderScene() {
curtains.render();
}
gsap.ticker.add(renderScene);
// Params passed to the curtains instance
const params = {
vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
// Include any variables to update the WebGL state here
uniforms: {
// ...
}
};
// Create a curtains plane for each slide
const planeElements = document.querySelectorAll(".slide");
planeElements.forEach((planeEl, i) => {
const plane = curtains.addPlane(planeEl, params);
// const plane = new Plane(curtains, planeEl, params); // v7 version
// If our plane has been successfully created
if(plane) {
// onReady is called once our plane is ready and all its texture have been created
plane.onReady(function() {
// Add a "loaded" class to display the image container
plane.htmlElement.closest(".slide").classList.add("loaded");
});
}
});
The same update function that tracks the slider's progress now also needs to inform the WebGL planes of their new positions:
function updateProgress() {
// Update the actual slider
animation.progress(wrapVal(this.x) / wrapWidth);
// Update the WebGL slider planes
planes.forEach(plane => plane.updatePosition());
}
A basic vertex and fragment shader are needed to display the texture. Vertex shaders position the plane, while fragment shaders process pixels. Shader variable prefixes follow GLSL conventions: in values come from a data buffer, uniforms are set from the CPU, and outs are passed to the next stage or the framebuffer. The GLSL 300 syntax used here requires a WebGL2 context, so Safari and Internet Explorer are excluded. For production, ship both GLSL 100 and 300 versions and use the latter only when the renderer reports support.
Text can be handled similarly by drawing it to a <canvas> and using that as the texture, though this approach does not scale well for arbitrary page content. CurtainsJS also ships a React wrapper if that environment is preferred.
Shader Effects Without a Memory
Shaders execute per frame with no persistent memory between calls. A shader cannot animate an effect or ease a value over time on its own — that state must live in JavaScript and be passed in as a uniform. For an interactive hover effect, that means parsing the effect into discrete pieces:
- Invert image colors outside a radius around the cursor.
- Inside that radius, map the image to a fisheye distortion.
- Animate the radius from zero when the cursor enters the slider, and back to zero on exit.
- Ease the radius position toward the cursor's location over time.
- Offset the image plane based on the cursor's proximity to the center.
Fisheye shader logic and color inversion live entirely in the shader. The animated radius and mouse tracking belong in JavaScript uniforms, which are then read by the GLSL code.
CurtainsJS sets up mouse coordinates relative to each plane as a built-in uniform, but custom effects need custom uniforms. The vertex shader wants the mouse position:
// The un-transformed mouse position
uniform vec2 uMouse;
The fragment shader takes the effect radius and the texture resolution:
uniform float uRadius; // Radius of pixels to warp/invert
uniform vec2 uResolution; // Used in anti-aliasing
Connecting Uniforms to Events
Uniforms are wired up when the CurtainsJS instance is configured, by specifying the shader variable name, its type, and an initial value:
const params = {
vertexShaderID: "slider-planes-vs", // The vertex shader we want to use
fragmentShaderID: "slider-planes-fs", // The fragment shader we want to use
// The variables that we're going to be animating to update our WebGL state
uniforms: {
// For the cursor effects
mouse: {
name: "uMouse", // The shader variable name
type: "2f", // The type for the variable - https://webglfundamentals.org/webgl/lessons/webgl-shaders-and-glsl.html
value: mouse // The initial value to use
},
radius: {
name: "uRadius",
type: "1f",
value: radius.val
},
// For the antialiasing
resolution: {
name: "uResolution",
type: "2f",
value: [innerWidth, innerHeight]
}
},
};
For the radius animation and its state, set up a GSAP tween with a callback that writes the animated state into the uniform value:
const radius = { val: 0.1 };
const radiusAnim = gsap.from(radius, {
val: 0,
duration: 0.3,
paused: true,
onUpdate: updateRadius
});
function updateRadius() {
planes.forEach((plane, i) => {
plane.uniforms.radius.value = radius.val;
});
}
The mouse uniform must track pointer movement while over the slider. Touch devices need separate handling. The position is passed in normalized coordinates that the vertex shader expects. While the page scrolls or the slider translates, a function runs to sync the WebGL plane positions:
const mouse = new Vec2(0, 0);
function addMouseListeners() {
if ("ontouchstart" in window) {
wrapper.addEventListener("touchstart", updateMouse, false);
wrapper.addEventListener("touchmove", updateMouse, false);
wrapper.addEventListener("blur", mouseOut, false);
} else {
wrapper.addEventListener("mousemove", updateMouse, false);
wrapper.addEventListener("mouseleave", mouseOut, false);
}
}
// Update the stored mouse position along with WebGL "mouse"
function updateMouse(e) {
radiusAnim.play();
if (e.changedTouches && e.changedTouches.length) {
e.x = e.changedTouches[0].pageX;
e.y = e.changedTouches[0].pageY;
}
if (e.x === undefined) {
e.x = e.pageX;
e.y = e.pageY;
}
mouse.x = e.x;
mouse.y = e.y;
updateWebGLMouse();
}
// Updates the mouse position for all planes
function updateWebGLMouse(dur) {
// update the planes mouse position uniforms
planes.forEach((plane, i) => {
const webglMousePos = plane.mouseToPlaneCoords(mouse);
updatePlaneMouse(plane, webglMousePos, dur);
});
}
// Updates the mouse position for the given plane
function updatePlaneMouse(plane, endPos = new Vec2(0, 0), dur = 0.1) {
gsap.to(plane.uniforms.mouse.value, {
x: endPos.x,
y: endPos.y,
duration: dur,
overwrite: true,
});
}
// When the mouse leaves the slider, animate the WebGL "mouse" to the center of slider
function mouseOut(e) {
planes.forEach((plane, i) => updatePlaneMouse(plane, new Vec2(0, 0), 1) );
radiusAnim.reverse();
}
Then the existing scroll or progress handler calls that same sync function to keep the mouse uniform current during drag moves:
// Update the slider along with the necessary WebGL variables
function updateProgress() {
// Update the actual slider
animation.progress(wrapVal(this.x) / wrapWidth);
// Update the WebGL slider planes
planes.forEach(plane => plane.updatePosition());
// Update the WebGL "mouse"
updateWebGLMouse(0);
}
Using GSAP for the animation layer also helps here: its callbacks land reliably at the end of animations, and the library maintains consistent timing across high and low refresh rate displays.
Beyond Sliders
Once a DOM element is tied to a WebGL plane, common image treatments are easier to layer in. A displacement effect, which shifts pixels based on the luminance of a grayscale texture, can be added by feeding the shader a noise or distortion image. The typical source is a generated perlin noise or similar semi-random pattern.
When the displacement is driven by slider velocity and animated over time rather than held static, it creates a natural, repeated distortion that matches the user's drag speed without precomputed keyframes. The same effect can then be reused across other DOM elements of the page, since it is just a shader wired to the page's interaction logic. Pre-built shader repositories for website-sized effects are still scarce; ShaderToy and VertexShaderArt house genuinely impressive demos, but neither targets the low-key image treatments typical of sites like the ones this technique most often appears on.



