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:
npm install threeThe 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:
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:
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:
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:
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:
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:
burstRef.current *= 0.95;
if (burstRef.current < 0.001) burstRef.current = 0;
uniforms.uBurst.value = p.clickBurst ? burstRef.current : 0;How to use it
<div style={{ width: '600px', height: '600px' }}>
<MagicRings
color="#fc42ff"
colorTwo="#42fcff"
ringCount={6}
speed={1}
followMouse
clickBurst
/>
</div>Key takeaways
- Storing props in a
refand 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.