How to Code a Subtle Shader Background Effect with React Three Fiber

Hey there šŸ‘‹! Iā€™m Matias, a creative web dev based in Buenos Aires, Argentina. I currently run joyco.studio, and we recently released the first version of our website. It features a comic-like dot grid shader background, and in this post, Iā€™ll show you how itā€™s coded, breaking it down into 4 simple steps.

But before we begin, Iā€™ll ask you to turn ON the compositional thinking switch. Getting to a complex render is the result of achieving smaller and less complex outputs first, and then blending them all into something interesting. Have a look at what we will be creating:

That being said, letā€™s start with a base dot grid.

Step 1: The Dot Grid

Our backgroundā€™s base aesthetic will be a dotted grid pattern, for which we only need a screen quad (full-screen plane geometry) and a custom shader material.

Making a screen quad is super simple since we donā€™t need any 3D projection calculation, so itā€™s even simpler than the average vertex shader:

void main() {
    gl_Position = vec4(position.xy, 0.0, 1.0);
}

And hereā€™s the fragment shader:

void main() {
    vec2 screenUv = gl_FragCoord.xy / resolution; // get uvs from pixel coord
    vec2 uv = coverUv(screenUv); // aspect correct

    vec2 gridUv = fract(uv * gridSize);

    gl_FragColor = vec4(gridUv.x, gridUv.y, 0.0, 1.0);
}
Full code and demo here

Thatā€™s it; now you have the base grid. Yes, I knowā€”why is it squared? what are those colors? where are my dots?! Weā€™re getting there. They are squared because our shader subdivides the screen coordinates that go from 0 (left) to 1 (right) into smaller chunks controlled by our gridSize uniform. Those colors are UVs for each grid box, basically local coordinates. The color grading is due to uv.x and uv.y being used as the red and green channels, respectively.

Our last thing to do here is to turn our pointy squares into round dots. We can do that by measuring the distance from the center of each local box; we can achieve it using the sdfCircle(point, radius) function (SDF stands for ā€œSigned Distance Functionā€œ). The size of the circle will be determined by the radiusā€”letā€™s say 0.3 (tweak it yourself and see the result!). Letā€™s update the fragment code:

//...
float baseDot = sdfCircle(gridUv, radius); // sdfCircle code available on demo below

gl_FragColor = vec4(vec3(baseDot), 1.0);
//...
Full code and demo here

If the result of the sdfCircle function is less than or equal to zero, then that pixel fragment is considered part of our circle. Thatā€™s why our circles are black in the center and as you go further away, they turn white 0 -> āˆž. There are a LOT of well-known SDF functions, andĀ Inigo Quilez caught ā€™em all.

Step 2: The Mouse Trail

@react-three/drei got us covered here; it has a hook for this. It handles the creation of a 2D canvas and drawing to it based on the onPointerMove event. The event.uv tells where the mouse intersection was. Check the source code here.

If it doesnā€™t start right away, click and move the mouse.

This is just perfect! Later, weā€™ll sample this mouse trail texture to highlight the dots that are being hovered. But we wonā€™t do it the ā€œeasyā€ way, which would be using the dots as a mask over the trail texture. Itā€™s not bad, but that would render the underlying trail texture gradient inside the circles. Instead, I want each circle to be color uniform, and we can achieve that by sampling the trail texture from the center of each grid box (which is the center of our dots too). See? Itā€™s essentially a pixelation effect.

Full code and demo here

Step 3: The Mask

The first is a radial gradient at y: 110% and x: 0.7 from the bottom-left.

A linear gradient from the screen top to the screen bottom.

And a time-based animated radial gradient with the center at the same point as the first one.

Weā€™ll only blend the first two, and the animated one will be used later. Hereā€™s the code:

float circleMaskCenter = length(uv - vec2(0.70, 1.0));
float circleMask = smoothstep(0.4, 1.0, circleMaskCenter);
float circleAnimatedMask = sin(time * 2.0 + circleMaskCenter * 10.0);

float screenMask = smoothstep(0.0, 1.0, 1.0 - uv.y);

// Blend
float combinedMask = screenMask * circleMask;

Step 4: The Composition

Yes! Started from the bottom, now we are here! We made it to our last step: blending it all together.

Letā€™s pick special colors for this special post (my first one in codrops šŸ„³) Iā€™ll use #FF5001 and #FFF for the background and dots respectively. The fun part is that we are free to tweak how each composition step interacts with each other. Eg, how much opacity adds the mouse trail to the dots? Should it scale them too? Can it also change their colors?! My answer here is ā€œFollow your heartā€œ. I made my choices for this demo but feel free to tweak them.

The dot scale and opacity are affected by the mouseTrail, combinedMask, and circleAnimatedMask.

// The mouse trail is a B&W image, we only need the red channel.
float mouseInfluence = texture2D(mouseTrail, gridUvCenterInScreenCoords).r;

float scaleInfluence = max(mouseInfluence * 0.5, circleAnimatedMask * 0.3);
float opacityInfluence = max(mouseInfluence * 15.0, circleAnimatedMask * 0.5);

float sdfDot = sdfCircle(gridUv, dotSize * (1.0 + scaleInfluence * 0.5));
float smoothDot = smoothstep(0.05, 0.0, sdfDot); // Smooth the edges

vec3 composition = mix(bgColor, dotColor, smoothDot * combinedMask * dotOpacity * (1.0 + opacityInfluence));

gl_FragColor = vec4(composition, 1.0);

As our last act, we should apply tone mapping and adjust the output to the rendererā€™s color space; otherwise, our colors wonā€™t display accurately. Before our shader gets compiled, threejs imports its internal lib chunks of code if it finds an #include <{chunk_name}> snippet in the shader. We can borrow the tonemapping_fragment and colorspace_fragment from there. Hereā€™s the code:

#include <tonemapping_fragment>
#include <colorspace_fragment>

FYI: Thereā€™s a whole library of shader chunks available in threejs check ā€™em out. It might save you some time in the future!

Thatā€™s it for this post! I hope you enjoyed diving into the creative process behind building a custom shader-powered dot grid and learning some new tricks along the way.

Thanks for following along, and donā€™t forget to eat your vegetables! See you around on X!