Dirck Mulder
Animations||4 min read

Create Magic Rings in React with WebGL

Render animated concentric rings with a WebGL shader that ripples outward in a continuous, hypnotic loop.

Concentric circles have a meditative quality. Water ripples, signal waves, sonar pings. There is something satisfying about watching rings expand outward, and with a WebGL shader you can make them glow with a depth that CSS simply cannot match.

The final result

What we are building

Multiple concentric rings pulse outward from the center in a continuous loop. Each ring has a slight color variation and phase offset, creating a layered, organic rhythm. Mouse interaction shifts the rings and a click triggers a burst. The whole thing runs at 60fps entirely on the GPU.

Setting up

You need Three.js:

bash
npm install three

The component manages its own WebGL renderer via useRef and a useEffect, rendering directly into a <div>.

Building the component

The fragment shader is where the rings are born. Each ring is computed in a ring() function that calculates radial distance, applies a time-based phase offset, and fades in and out over a cycle:

glsl
float ring(vec2 p, float ri, float cut, float t0, float px) {
  float t = mod(uTime + t0, CYCLE);
  float r = ri + t / CYCLE * uScaleRate;
  float d = abs(length(p) - r);
  float a = atan(abs(p.y), abs(p.x)) / HP;
  float th = max(1.0 - a, 0.5) * px * uLineThickness;
  float h = (1.0 - smoothstep(th, th * 1.5, d)) + 1.0;
  d += pow(cut * a, 3.0) * r;
  return h * exp(-uAttenuation * d) * fade(t);
}

The fade() function uses smoothstep to ramp the ring in and out over its lifetime, which prevents any harsh popping at cycle boundaries:

glsl
float fade(float t) {
  return t < uFadeIn ? smoothstep(0.0, uFadeIn, t) : 1.0 - smoothstep(uFadeOut, CYCLE - 0.2, t);
}

The main loop runs up to 10 rings, mixing color between uColor and uColorTwo across the ring count:

glsl
for (int i = 0; i < 10; i++) {
  if (i >= uRingCount) break;
  float fi = float(i);
  vec2 pr = p - fi * uParallax * uMouse;
  vec3 rc = mix(uColor, uColorTwo, fi / rcf);
  c = mix(c, rc, vec3(ring(pr, uBaseRadius + fi * uRadiusStep, ...)));
}

On the JavaScript side, props are stored in a propsRef so the animation loop can read them without needing to restart when props change:

tsx
propsRef.current = { color, colorTwo, speed, ringCount, ... };

const animate = (t: number) => {
  frameId = requestAnimationFrame(animate);
  const p = propsRef.current!;
  uniforms.uTime.value = t * 0.001 * p.speed;
  uniforms.uColor.value.set(p.color);
  // ... update all uniforms from current props
  renderer.render(scene, camera);
};

Mouse tracking smooths the cursor position and feeds it to the shader:

tsx
smoothMouseRef.current[0] += (mouseRef.current[0] - smoothMouseRef.current[0]) * 0.08;
smoothMouseRef.current[1] += (mouseRef.current[1] - smoothMouseRef.current[1]) * 0.08;
uniforms.uMouse.value.set(smoothMouseRef.current[0], smoothMouseRef.current[1]);

The click burst decays each frame:

tsx
burstRef.current *= 0.95;
if (burstRef.current < 0.001) burstRef.current = 0;
uniforms.uBurst.value = p.clickBurst ? burstRef.current : 0;

How to use it

tsx
<div style={{ width: '600px', height: '600px' }}>
  <MagicRings
    color="#fc42ff"
    colorTwo="#42fcff"
    ringCount={6}
    speed={1}
    followMouse
    clickBurst
  />
</div>

Key takeaways

  • Storing props in a ref and reading from it inside the animation loop is a clean pattern for keeping WebGL uniforms in sync without restarting the render loop on every prop change.
  • The fade() function with smoothstep ramps prevents visible cycle boundaries, making the loop feel truly continuous.
  • Noise is added in the final step with a hash function to break up banding artifacts that pure math rings tend to produce.