On this tutorial, you’ll learn to construct a scroll-driven, infinitely looping 3D picture tube utilizing React Three Fiber. We’ll mix shader-based deformation, inertial movement, deterministic looping, and synchronized DOM overlays to create a tactile and bodily coherent WebGL expertise.
1. Introduction
On this tutorial, we’re going to construct an interactive 3D scene fabricated from three primary components:
- A grid within the background that reacts to your mouse
- A cylindrical tube of pictures that scrolls up and down
- A glass helmet that rotates with the tube
On high of that, we’ll add:
- A hover impact that lightly slows all the pieces down
- A tooltip constructed within the DOM that follows the mouse
- A easy customized cursor
The aim isn’t realism. It’s about making a scene the place all the pieces feels linked.
Scrolling, transferring the mouse, hovering — all of them affect the identical movement system.
2. Movement as a Shared Sign
As a substitute of treating every interplay individually, we let all the pieces have an effect on the identical system.
- Scroll strikes the tube vertically
- Scroll pace provides rotation
- The mouse place adjustments the form of the grid
- Hover slows down time
All of the essential values dwell inside useRef:
const tubeScrollTarget = useRef(0);
const tubeSpinVelocity = useRef(0);
const tubeAngle = useRef(0);
const rotationSpeedScaleTargetRef = useRef(1);
Inside useFrame, we replace all the pieces each body:
useFrame((_state, dt) => {
scrollCurrent.present +=
(scrollTargetRef.present - scrollCurrent.present) * 0.12;
spinVelocityRef.present *= Math.pow(0.92, dt * 60);
rotationSpeedScale.present +=
(rotationSpeedScaleTargetRef.present - rotationSpeedScale.present) *
rotationSpeedScaleLerpRef.present;
const scaledDt = dt * rotationSpeedScale.present;
angle.present +=
(baseSpeedRef.present + spinVelocityRef.present) * scaledDt;
tubeAngleRef.present = angle.present;
});
We don’t use React state right here. Nothing re-renders each body. Every little thing stays contained in the animation loop.
3. The Grid Aircraft: Deforming Geometry within the Vertex Shader
The grid is only a aircraft, however it has quite a lot of subdivisions:
We need many segments because we’re moving the vertices in the shader.
Here’s the vertex shader:
varying vec2 vUv;
uniform float uEdgeWidth;
uniform float uEdgeAmp;
uniform float uCenterRadius;
uniform float uCenterAmp;
uniform vec2 uCenter;
void main() {
vUv = uv;
vec3 p = position;
float dEdge = min(
min(vUv.x, 1.0 - vUv.x),
min(vUv.y, 1.0 - vUv.y)
);
float edgeMask = 1.0 - smoothstep(0.0, uEdgeWidth, dEdge);
float dCenter = distance(vUv, uCenter);
float centerMask = 1.0 - smoothstep(0.0, uCenterRadius, dCenter);
float zOffset = edgeMask * uEdgeAmp
+ centerMask * uCenterAmp;
p.z += zOffset;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}
Here’s what happens in simple terms:
- We measure how close each vertex is to the edge of the plane
- We measure how close it is to the mouse position
- We use
smoothstepto make both effects fade smoothly - We push the vertex forward in Z
There are no hard edges, no sudden jumps. Everything blends smoothly.
4. Drawing the Grid in the Fragment Shader
The grid itself is not a texture. It’s generated mathematically.
First, we animate it over time:
vec2 uv = (vUv + vec2(uTime * uScrollSpeed, 0.0)) * uGridScale;
Then we define a function that draws a line:
float gridLine(float coord, float width) {
float fw = fwidth(coord);
float p = abs(fract(coord - 0.5) - 0.5);
return 1.0 - smoothstep(width * fw, (width + 1.0) * fw, p);
}
The key ideas:
fract()repeats a value between 0 and 1, so the pattern tiles infinitely- The
abs(fract(x - 0.5) - 0.5)trick gives us distance from the center of each cell fwidth()makes the lines anti-aliased and stable at any resolution
Full fragment logic:
float gx = gridLine(uv.x, uLineWidth);
float gy = gridLine(uv.y, uLineWidth);
float g = max(gx, gy);
vec3 base = vec3(0.0);
vec3 line = vec3(0.1);
gl_FragColor = vec4(mix(base, line, g), 1.0);
Without fwidth, the lines would shimmer while moving.
5. Seamless Vertical Looping
The tube is not infinite. We just reposition it when needed.
if (scrollCurrent.current > loopHeight / 2) {
scrollCurrent.current -= loopHeight;
scrollTargetRef.current -= loopHeight;
}
We adjust both the current position and the target value. That’s what prevents visible jumps. Each image is positioned around a circle:
const theta = ((col + rowOffset) / cols) * Math.PI * 2;
const x = Math.cos(theta) * radius;
const z = Math.sin(theta) * radius;
const ry = -(theta + Math.PI / 2);
Each plane faces outward from the center.
6. Inertia and Damping
Scroll doesn’t directly rotate the tube. It adds velocity.
tubeSpinVelocity.current += event.deltaY * 0.004;
Every frame, we damp it:
spinVelocityRef.current *= Math.pow(0.92, dt * 60);
And clamp it:
spinVelocityRef.current = Math.max(
-2.0,
Math.min(2.0, spinVelocityRef.current)
);
That’s what gives us smooth, controlled motion instead of chaos.
7. Hover Slows Down Time
When you hover an image, we don’t change rotation directly. We slow down time.
rotationSpeedScaleTargetRef.current = 0.35;
Inside the loop:
rotationSpeedScale.current +=
(rotationSpeedScaleTargetRef.current - rotationSpeedScale.current) *
rotationSpeedScaleLerpRef.current;
const scaledDt = dt * rotationSpeedScale.current;
Because we scale dt, the whole system slows down consistently. The inertia still makes sense.
8. Controlling Event Propagation
Each mesh stops event bubbling:
onPointerOver={(e) => {
e.stopPropagation();
onHoverStart(projectName, e);
}}
This prevents hover events from interfering with the container-level pointer tracking.
10. Performance
- No raycasting
- No React state inside the animation loop
- No per-frame allocations
- Shader-driven deformation
- DOM animations handled outside React
The frame rate stays stable even with strong scroll input.
Wrapping Up
This isn’t just a collection of animations. It’s one connected motion system.
Scroll adds energy. Energy creates rotation. Hover slows time. The shader reshapes space. The DOM reacts to interaction.
Make sure to check out all variations:









