Dirck Mulder
Backgrounds||4 min read

Build a Color Bends Shader in React

Animate fluid, bending color gradients with a WebGL fragment shader that warps hues as if the screen itself is made of liquid.

A gradient that actually moves is a different beast from a CSS keyframe sliding a color stop. ColorBends uses a GLSL fragment shader running in Three.js to warp coordinate space itself, making colors bend and fold like liquid on glass.

The final result

What we are building

A full-canvas WebGL background where up to eight custom colors flow and distort continuously. The warping is mouse-aware, and you can rotate the entire color field or let it auto-spin. No textures are needed, everything is computed per-pixel at runtime.

Setting up

bash
npm install three
tsx
import * as THREE from 'three';
import React, { useEffect, useRef } from 'react';

Building the component

The shader lives in a GLSL string. The vertex shader is trivial, just a pass-through for UV coordinates. The fragment shader does the real work.

First, the coordinate setup. UV coordinates are centered and aspect-corrected, then rotated by the uRot uniform (a precomputed sin/cos pair):

glsl
void main() {
  float t = uTime * uSpeed;
  vec2 p = vUv * 2.0 - 1.0;
  p += uPointer * uParallax * 0.1;
  vec2 rp = vec2(p.x * uRot.x - p.y * uRot.y, p.x * uRot.y + p.y * uRot.x);
  vec2 q = vec2(rp.x * (uCanvas.x / uCanvas.y), rp.y);
  q /= max(uScale, 0.0001);
  q /= 0.5 + 0.2 * dot(q, q);
  q += 0.2 * cos(t) - 7.56;

The q /= 0.5 + 0.2 * dot(q, q) line is where the bending comes from. It computes a fisheye-style distortion based on distance from the center, making the coordinate space fold inward.

Then each color layer is accumulated by iterating through the uColors array:

glsl
for (int i = 0; i < MAX_COLORS; ++i) {
  if (i >= uColorCount) break;
  s -= 0.01;
  vec2 r = sin(1.5 * (s.yx * uFrequency) + 2.0 * cos(s * uFrequency));
  float m0 = length(r + sin(5.0 * r.y * uFrequency - 3.0 * t + float(i)) / 4.0);
  float kBelow = clamp(uWarpStrength, 0.0, 1.0);
  float kMix = pow(kBelow, 0.3);
  float gain = 1.0 + max(uWarpStrength - 1.0, 0.0);
  vec2 disp = (r - s) * kBelow;
  vec2 warped = s + disp * gain;
  float m1 = length(warped + sin(5.0 * warped.y * uFrequency - 3.0 * t + float(i)) / 4.0);
  float m = mix(m0, m1, kMix);
  float w = 1.0 - exp(-6.0 / exp(6.0 * m));
  sumCol += uColors[i] * w;
  cover = max(cover, w);
}

The w = 1.0 - exp(-6.0 / exp(6.0 * m)) is a smooth thresholding function. When m is small (near the color center), w approaches 1. When m is large, w falls off exponentially. Each color region bleeds naturally into its neighbors.

In React, set up Three.js with an orthographic camera so the plane fills the screen:

tsx
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1);
const geometry = new THREE.PlaneGeometry(2, 2);
const material = new THREE.ShaderMaterial({
  vertexShader: vert,
  fragmentShader: frag,
  uniforms: {
    uCanvas: { value: new THREE.Vector2(1, 1) },
    uTime: { value: 0 },
    uColors: { value: uColorsArray },
    uColorCount: { value: 0 },
    uWarpStrength: { value: warpStrength },
    // ... all other uniforms
  },
  transparent: true,
});

Mouse pointer tracking sends a smoothed pointer position to the shader each frame:

tsx
const amt = Math.min(1, dt * pointerSmoothRef.current);
cur.lerp(tgt, amt);
(material.uniforms.uPointer.value as THREE.Vector2).copy(cur);

How to use it

tsx
<div className="relative h-screen">
  <ColorBends
    colors={['#ff6b6b', '#4ecdc4', '#45b7d1', '#96ceb4']}
    speed={0.2}
    warpStrength={1}
    frequency={1}
    noise={0.05}
  />
  <div className="relative z-10">Your content</div>
</div>

Pass an empty colors array to get the default RGB channel rendering, which generates a psychedelic but color-accurate output.

Key takeaways

  • The fisheye distortion q /= 0.5 + 0.2 * dot(q, q) bends the coordinate space before any color lookup, giving the effect its liquid quality.
  • warpStrength above 1 extrapolates the displacement rather than clamping it, which creates sharper folds for a more dramatic look.
  • Film grain via the uNoise uniform adds texture and prevents banding on gradients displayed on 8-bit monitors.