From our partner: The AI visual builder for Next.js & Tailwind: Generate UI with AI. Customize it with a visual editor.
In this beginner-friendly tutorial, creative developer Paola Demichelis takes us behind the scenes of one of her playful interactive experiments. She shows us how she brought this beautiful effect to life using Three.js and custom shaders—breaking it down step by step so you can follow along, even if you’re just getting started with shaders.
Ciao! I like to imagine that letters sometimes get bored of being stuck on their rigid, two-dimensional surface. Every now and then, they need a little push to stretch and break free from their flat existence.
I’ve put together this quick tutorial for anyone getting started with shaders in Three.js. It covers the basics of creating a ShaderMaterial, the fundamentals of interaction using Raycasting, and how to combine the two. With just a few lines of code, you’ll see how easy it is to create a fun and dynamic interactive effect like the one shown below.
Prepare Your Assets
First, let’s prepare the textures we’ll be using for displacement.
For this project, we need two variations, both in PNG format: one is a solid black texture, and the other is the shadow texture—a blurred and semi-transparent version of the first. (Technically, you could generate the blur effect using a GLSL shader, but it can be quite performance-heavy. Since our text will be static, this simple trick works just fine!)
A quick note on the ratio: both textures are square (1:1), but if you decide to use a different ratio, remember to adjust the aspect ratio of the plane geometry accordingly.

Create a Basic Scene
Time to start coding! If this is your first time creating a scene in Three.js, check out this link for a great introduction to all the fundamental elements needed to render a basic scene—such as the scene itself, the camera, the renderer, and more. For this project, I’ve opted for an Orthographic Camera and positioned it to provide a diagonal view, giving us an optimal perspective on the displacement effect.
In this basic scene, we’re also introducing our hero element: the plane, where we’ll apply the displacement effect.
All the magic happens inside its custom ShaderMaterial. For now, this material simply maps the texture image onto the plane using its UV coordinates. To do this, we pass the texture we created earlier into the shader as a uniform.
Below, you’ll see the starting code for our ShaderMaterial, along with the corresponding Vertex Shader and Fragment Shader.
You can find more detailed information about ShaderMaterial at this link.
//basic texture shader
let shader_material = new THREE.ShaderMaterial({
uniforms: {
uTexture: { type: "t", value: new THREE.TextureLoader().load(texture) }
},
vertexShader: `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uTexture;
void main(){
vec4 color = texture2D(uTexture, vUv);
gl_FragColor = vec4(color) ;
}`,
transparent: true,
side: THREE.DoubleSide
});
See the Pen Basic Plane Scene by Paola Demichelis (@Paola-Demichelis-the-lessful) on CodePen.
Nice! We’ve got something—but it’s still a bit boring. We need to add some interaction!
Interaction with Raycaster
Raycaster is a powerful feature in Three.js for detecting interactions between the mouse and objects in a 3D scene. You can find more about it at this link. The Raycaster checks for intersections between a ray and the objects in the scene. Not only does it return a list of intersected objects, but it also provides valuable information about the exact point of collision—which is exactly what we need.
We create an invisible, much larger plane that serves as a target for raycasting. Even though it’s invisible, it still exists in the 3D world and can be interacted with. This plane is named “hit”, allowing us to uniquely identify it when performing raycasting in the scene.
To help visualize how raycasting works, we use a small red sphere as a marker for the intersection point between the ray and the surface of the hit plane. This sphere moves to the point of intersection, indicating where the displacement—or any other interaction—will occur.
In the onPointerMove event (which triggers every time the mouse moves), we cast a ray from the mouse position. The ray checks for intersections with the invisible hit plane. When a hit is detected, the intersection point is calculated and we update the red sphere’s position to match. This makes it look like the sphere is “following” the mouse as it moves across the screen.
To recap, here’s the most significant part of this process:
const raycaster = new THREE.Raycaster();
const pointer = new THREE.Vector2();
window.addEventListener("pointermove", onPointerMove);
function onPointerMove(event) {
pointer.x = (event.clientX / window.innerWidth) * 2 - 1;
pointer.y = -(event.clientY / window.innerHeight) * 2 + 1;
raycaster.setFromCamera(pointer, camera);
const intersects = raycaster.intersectObject(hit);
if (intersects.length > 0) {
sphere.position.set(
intersects[0].point.x,
intersects[0].point.y,
intersects[0].point.z
);
}
}
See the Pen Raycasting by Paola Demichelis (@Paola-Demichelis-the-lessful) on CodePen.
Add the Displacement
We now have all the necessary elements: the plane, and the point where the mouse intersects it. Here’s how to combine them in 4 steps:
1) Passing the Collision Point to the Shader: Since the coordinates of the collision point are the same as the position of the red sphere, we can send those coordinates directly to the shader. This is done by passing the world coordinates of the collision point as a uniform to the shader.
So, we add the uDisplacement uniform to the shader.
uniforms: {
uTexture: { type: "t", value: new THREE.TextureLoader().load(texture) },
uDisplacement: { value: new THREE.Vector3(0, 0, 0) }
},
And in the onPointerMove event:
shader_material.uniforms.uDisplacement.value = sphere.position;
2) Calculating the Distance in the Vertex Shader: In the vertex shader, we’ll use these world coordinates to calculate the distance from each vertex of the plane to the collision point. Since the collision point is in world space, it’s important that we perform this calculation in world coordinates as well to ensure accurate results.
vec4 localPosition = vec4( position, 1.);
vec4 worldPosition = modelMatrix * localPosition;
float dist = (length(uDisplacement - worldPosition.rgb));
3) Defining the Displacement Radius: We can define a radius around the collision point within which the displacement effect will be applied. If a vertex falls within this radius, we displace it along the Z-axis. This creates the illusion of a “ripple” or “bump” effect on the plane, reacting to the mouse position.
//min_distance is the radius of displacement
float min_distance = 3.;
if (dist < min_distance){
....
}
4) Applying Displacement Based on Distance: Inside the vertex shader, we calculate the distance between the hit point and each vertex. If the distance is smaller than the defined radius, we apply a displacement effect by adjusting the Z-axis value of that vertex. This creates the visual effect of the surface being displaced around the point of intersection.
float distance_mapped = map(dist, 0., min_distance, 1., 0.);
float val = easeInOutCubic(distance_mapped);
new_position.z += val;
…and then:
gl_Position = projectionMatrix * modelViewMatrix * vec4(new_position,1.0);
To make the effect smoother, I’ve added an easing function that creates a more gradual transition from the outer radius to the center of the collision point. I highly recommend experimenting with easing functions like these ones, as they can add a more natural feel to your effect. Since these easing functions require values between 0 and 1, I used the map
function from p5.js to scale the distance range appropriately.
If the displacement appears blocky instead of smooth, it’s likely because you need to increase the number of segments defining the subdivision surface of the PlaneGeometry:
var geometry = new THREE.PlaneGeometry(15, 15, 100, 100);
Here is the full material updated:
let shader_material = new THREE.ShaderMaterial({
uniforms: {
uTexture: { type: "t", value: new THREE.TextureLoader().load(texture) },
uDisplacement: { value: new THREE.Vector3(0, 0, 0) }
},
vertexShader: `
varying vec2 vUv;
uniform vec3 uDisplacement;
float easeInOutCubic(float x) {
return x < 0.5 ? 4. * x * x * x : 1. - pow(-2. * x + 2., 3.) / 2.;
}
float map(float value, float min1, float max1, float min2, float max2) {
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
}
void main() {
vUv = uv;
vec3 new_position = position;
vec4 localPosition = vec4( position, 1.);
vec4 worldPosition = modelMatrix * localPosition;
//dist is the distance to the displacement point
float dist = (length(uDisplacement - worldPosition.rgb));
//min_distance is the radius of displacement
float min_distance = 3.;
if (dist < min_distance){
float distance_mapped = map(dist, 0., min_distance, 1., 0.);
float val = easeInOutCubic(distance_mapped) * 1.; //1 is the max height of displacement
new_position.z += val;
}
gl_Position = projectionMatrix * modelViewMatrix * vec4(new_position,1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
uniform sampler2D uTexture;
void main()
{
vec4 color = texture2D(uTexture, vUv);
gl_FragColor = vec4(color) ;
}`,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide
});
See the Pen Displacement by Paola Demichelis (@Paola-Demichelis-the-lessful) on CodePen.
Shadow Effect
Even though it’s already looking great, we can push it a bit further by adding a shadow effect to create a more realistic 3D appearance. To do this, we need a second plane. However, this time we don’t need displacement—instead, we’ll modify the colors to simulate illumination and shadow, using the blurred texture we prepared in the first step.
While we previously focused more on the Vertex Shader, now we’ll shift our attention to the Fragment Shader to create the shadow effect. Using the same logic as before, we calculate the distance in the Vertex Shader, then pass it to the Fragment Shader as a varying variable to determine the alpha value of the texture.
One important note: the radius for the minimum distance needs to remain consistent between both planes. This ensures the shadow effect aligns correctly with the displacement, creating a seamless result.
let shader_material_shadow = new THREE.ShaderMaterial({
uniforms: {
uTexture: {
type: "t",
value: new THREE.TextureLoader().load(shadow_texture)
},
uDisplacement: { value: new THREE.Vector3(0, 0, 0) }
},
vertexShader: `
varying vec2 vUv;
varying float dist;
uniform vec3 uDisplacement;
void main() {
vUv = uv;
vec4 localPosition = vec4( position, 1.);
vec4 worldPosition = modelMatrix * localPosition;
dist = (length(uDisplacement - worldPosition.rgb));
gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
}
`,
fragmentShader: `
varying vec2 vUv;
varying float dist;
uniform sampler2D uTexture;
float map(float value, float min1, float max1, float min2, float max2) {
return min2 + (value - min1) * (max2 - min2) / (max1 - min1);
}
void main()
{
vec4 color = texture2D(uTexture, vUv);
float min_distance = 3.;
if (dist < min_distance){
float alpha = map(dist, min_distance, 0., color.a , 0.);
color.a = alpha;
}
gl_FragColor = vec4(color) ;
}`,
transparent: true,
depthWrite: false,
side: THREE.DoubleSide
});
See the Pen Shadow by Paola Demichelis (@Paola-Demichelis-the-lessful) on CodePen.
What’s Next?
This is the end of the tutorial—but the beginning of your experiments! I’m excited to see what you’ll create. For example, I made a different version where I used something a bit more unconventional than a mouse, or added more distortion to the displacement.
Have fun, and happy days ☺