Dirck Mulder
Animations||4 min read

Build a Metallic Paint Shader in React

A WebGL fragment shader renders a fluid metallic surface with light streaks and oil-slick color gradients.

Metal doesn't have one color. It reflects everything around it: streaks of light, distorted reflections, color shifts as the angle changes. Capturing that in a browser is no longer a compromise. With a well-tuned WebGL2 shader and a depth map baked from your image, you can get genuinely convincing metallic surfaces.

The final result

What we are building

The component takes an image, computes a depth map from it using a CPU-side Poisson solver, uploads that as a texture, and then runs a fragment shader that simulates metallic paint flowing across the shape. The shader handles chromatic spread, liquid distortion, contour lines, and a rotating light sweep.

Setting up

No external libraries. The component uses raw WebGL2 via a canvas ref.

tsx
interface MetallicPaintProps {
  imageSrc: string;
  seed?: number;
  scale?: number;
  refraction?: number;
  blur?: number;
  liquid?: number;
  speed?: number;
  lightColor?: string;
  darkColor?: string;
  tintColor?: string;
}

Building the component

The first step is processing the image on the CPU to generate a depth map. The processImage function draws the image to a canvas, extracts pixel data, builds a shape mask from non-background pixels, then runs a Successive Over-Relaxation (SOR) solver to compute depth values that peak at the center of the shape and fall to zero at edges:

tsx
const ITERATIONS = 200;
const omega = 1.85;

for (let iter = 0; iter < ITERATIONS; iter++) {
  for (let y = 1; y < height - 1; y++) {
    for (let x = 1; x < width - 1; x++) {
      const idx = y * width + x;
      if (!shapeMask[idx] || boundaryMask[idx]) continue;
      const sum = (shapeMask[idx + 1] ? u[idx + 1] : 0) + ...;
      const newVal = (C + sum) / 4;
      u[idx] = omega * newVal + (1 - omega) * u[idx];
    }
  }
}

The resulting depth values are encoded as grayscale in the red channel, with the alpha channel carrying the original shape mask. This single texture gives the shader both depth and clipping information.

The WebGL setup compiles the shaders, links the program, and queries all uniform locations in one pass:

tsx
const count = gl.getProgramParameter(prog, gl.ACTIVE_UNIFORMS);
for (let i = 0; i < count; i++) {
  const info = gl.getActiveUniform(prog, i);
  if (info) uniforms[info.name] = gl.getUniformLocation(prog, info.name);
}

The render loop updates u_time each frame. When mouseAnimation is enabled, it maps mouse X and Y position to the time value instead, letting the user scrub through the animation by moving the cursor:

tsx
if (mouseAnimRef.current) {
  mouse.x += (mouse.targetX - mouse.x) * 0.08;
  mouse.y += (mouse.targetY - mouse.y) * 0.08;
  animTimeRef.current = mouse.x * 3000 + mouse.y * 1500;
} else {
  animTimeRef.current += delta * speedRef.current;
}

gl.uniform1f(u.u_time, animTimeRef.current);
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);

The component uses two separate useEffect hooks: one to initialize the WebGL context, and one to load the image and upload the texture. This lets the canvas be ready before the image finishes loading:

tsx
useEffect(() => {
  if (!ready || !imageSrc) return;
  const img = new Image();
  img.crossOrigin = 'anonymous';
  img.onload = () => {
    const imgData = processImage(img);
    uploadTexture(imgData);
    setTextureReady(true);
  };
  img.src = imageSrc;
}, [ready, imageSrc, uploadTexture]);

How to use it

tsx
<div style={{ width: '400px', height: '400px' }}>
  <MetallicPaint
    imageSrc="/my-logo.png"
    lightColor="#ffffff"
    darkColor="#000000"
    tintColor="#feb3ff"
    speed={0.3}
    scale={4}
  />
</div>

Key takeaways

  • The SOR Poisson solver on the CPU generates a depth map that the GPU shader uses to simulate how light wraps around the shape. Without it, the metallic effect would look flat.
  • Storing speed in a ref and reading from it in the render loop means you can change the speed prop without restarting the animation or re-uploading the texture.
  • The mouseAnimation mode remaps mouse position to the time uniform, which turns the animation into a direct scrubbing control rather than an autonomous loop.