How to Create a Sticky Image Effect with Three.js

A recreation of the sticky image effect seen on the websites of MakeReign and Ultranoir using three.js.

If you recently browsed Awwwards or FWA you might have stumbled upon Ultranoir’s website. An all-round beautifully crafted website, with some amazing WebGL effects. One of which is a sticky effect for images in their project showcase. This tutorial is going to show how to recreate this special effect.

The same kind of effect can be seen on the amazing website of MakeReign.

Understanding the effect

When playing with the effect a couple of times we can make a very simple observation about the “stick”.

In either direction of the effect, the center always reaches its destination first, and the corners last. They go at the same speed, but start at different times.

With this simple observation we can extrapolate some of the things we need to do:

  1. Differentiate between the unsticky part of the image which is going to move normally and the sticky part of the image which is going to start with an offset. In this case, the corners are sticky and the center is unsticky.
  2. Sync the movements
    1. Move the unsticky part to the destination while not moving the sticky part.
    2. When the unsticky part reaches its destination, start moving the sticky part

Getting started

For this recreation we’ll be using three.js, and Popmotion’s Springs. But you can implement the same concepts using other libraries.

We’ll define a plane geometry with its height as the view height, and its width as 1.5 of the view width.

const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 10000);
const fovInRadians = (camera.fov * Math.PI) / 180;
// Camera aspect ratio is 1. The view width and height are equal.
const viewSize = Math.abs(camera.position.z * Math.tan(fovInRadians / 2) * 2);
const geometry = new THREE.PlaneBufferGeometry(viewSize *1.5,viewSize,60,60)

Then we’ll define a shader material with a few uniforms we are going to use later on:

  • u_progress Elapsed progress of the complete effect.
  • u_direction Direction to which u_progress is moving.
  • u_offset Largest z displacement
const material = new THREE.ShaderMaterial({
	uniforms: {
		// Progress of the effect
		u_progress: { type: "f", value: 0 },
		// In which direction is the effect going
		u_direction: { type: "f", value: 1 },
		u_waveIntensity: { type: "f", value: 0 }
	},
	vertexShader: vertex,
	fragmentShader: fragment,
	side: THREE.DoubleSide
});

We are going to focus on the vertex shader since the effect mostly happens in there. If you have an interest in learning about the things that happen in the fragment shader, check out the GitHub repo.

Into the stick

To find which parts are going to be sticky we are going to use a normalized distance from the center. Lower values mean less stickiness, and higher values mean more sticky. Since the corners are the farthest away from the center, they end up being most sticky.

Since our effect is happening in both directions, we are going to have it stick both ways. We have two separate variables:

  1. One that will stick to the front. Used when the effect is moving away from the screen.
  2. And a second one that will stick to the back. Used when the effect is moving towards the viewer.
uniform float u_progress;
uniform float u_direction;
uniform float u_offset;
uniform float u_time;
void main(){
	vec3 pos = position.xyz;
	float distance = length(uv.xy - 0.5 );
	float maxDistance = length(vec2(0.5,0.5));
	float normalizedDistance = distance/sizeDist;
	// Stick to the front
	float stickOutEffect = normalizedDistance ;
	// Stick to the back
	float stickInEffect = -normalizedDistance ;
	float stickEffect = mix(stickOutEffect,stickInEffect, u_direction);
	pos.z += stickEffect * u_offset;
	gl_Position =
	projectionMatrix *
	modelViewMatrix *
	vec4(pos, 1.0);
}

Depending on the direction, we are going to determine which parts are not going to move as much. Until we want them to stop being sticky and move normally.

The Animation

For the animation we have a few options to choose from:

  1. Tween and timelines: Definitely the easiest option. But we would have to reverse the animation if it ever gets interrupted which would look awkward.
  2. Springs and vertex-magic: A little bit more convoluted. But springs are made so they feel more fluid when interrupted or have their direction changed.

In our demo we are going to use Popmotion’s Springs. But tweens are also a valid option and ultranoir’s website actually uses them.

Note: When the progress is either 0 or 1, the direction will be instant since it doesn’t need to transform.

function onMouseDown(){
	...
	const directionSpring = spring({
		from: this.progress === 0 ? 0 : this.direction,
		to: 0,
		mass: 1,
		stiffness: 800,
		damping: 2000
	});
	const progressSpring = spring({
		from: this.progress,
		to: 1,
		mass: 5,
		stiffness: 350,
		damping: 500
	});
	parallel(directionSpring, progressSpring).start((values)=>{
		// update uniforms
	})
	...
}

function onMouseUp(){
	...
	const directionSpring = spring({
		from: this.progress === 1 ? 1 : this.direction,
		to: 1,
		mass: 1,
		stiffness: 800,
		damping: 2000
	});
	const progressSpring = spring({
		from: this.progress,
		to: 0,
		mass: 4,
		stiffness: 400,
		damping: 70,
		restDelta: 0.0001
	});
	parallel(directionSpring, progressSpring).start((values)=>{
		// update uniforms
	})
	...
}

And we are going to sequence the movements by moving through a wave using u_progress.

This wave is going to start at 0, reach 1 in the middle, and come back down to 0 in the end. Making it so the stick grows in the beginning and decreases in the end.

void main(){
	...
	float waveIn = u_progress*(1. / stick);
	float waveOut = -( u_progress - 1.) * (1./(1.-stick) );
	float stickProgress = min(waveIn, waveOut);
	pos.z += stickEffect * u_offset * stickProgress;
	gl_Position =
	projectionMatrix *
	modelViewMatrix *
	vec4(pos, 1.0);
}

Now, the last step is to move the plane back or forward as the stick is growing.

Since the stick grow starts in different values depending on the direction, we’ll also move and start the plane offset depending on the direction.

void main(){
	...
	float offsetIn = clamp(waveIn,0.,1.);
	// Invert waveOut to get the slope moving upwards to the right and move 1 the left
	float offsetOut = clamp(1.-waveOut,0.,1.);
	float offsetProgress = mix(offsetIn,offsetOut,u_direction);
	pos.z += stickEffect * u_offset * stickProgress - u_offset * offsetProgress;
	gl_Position =
	projectionMatrix *
	modelViewMatrix *
	vec4(pos, 1.0);
}

And here is the final result:

Conclusion

Simple effects like this one can make our experience look and feel great. But they only become amazing when complemented with other amazing details and effects. In this tutorial we’ve covered the core of the effect seen on ultranoir’s website, and we hope that it gave you some insight on the workings of such an animation. If you’d like to dive deeper into the complete demo, please feel free to explore the code.

We hope you enjoyed this tutorial, feel free to share your thoughts and questions in the comments!

Daniel Velasquez

Daniel is a freelance front-end developer with a passion for interactive experiences. He enjoys deconstructing interesting websites and sharing ideas. Owns a diary and is writing about you right now.

Stay in the loop: Get your dose of frontend twice a week

👾 Hey! Looking for the latest in frontend? Twice a week, we'll deliver the freshest frontend news, website inspo, cool code demos, videos and UI animations right to your inbox.

Zero fluff, all quality, to make your Mondays and Thursdays more creative!

Feedback 10

Comments are closed.
  1. This is really great! A little bummed that this doesn’t explain the really great cursor/hover effect with the RGBA channel separation. Can you divulge a little bit on how that works?

    • It looks to me like this is the code responsible for this effect:
      uv.x -= sin(uv.y) * ratio / 100. * (vel.x + vel.y) / 7.; uv.y -= sin(uv.x) * ratio / 100. * (vel.x + vel.y) / 7.; tex1.r = texture2D(u_texture, centeredAspectRatio(uv, u_textureFactor )).r; tex2.r = texture2D(u_texture2, centeredAspectRatio(uv, u_textureFactor )).r; uv.x -= sin(uv.y) * ratio / 150. * (vel.x + vel.y) / 7.; uv.y -= sin(uv.x) * ratio / 150. * (vel.x + vel.y) / 7.; tex1.g = texture2D(u_texture, centeredAspectRatio(uv, u_textureFactor )).g; tex2.g = texture2D(u_texture2, centeredAspectRatio(uv, u_textureFactor )).g; uv.x -= sin(uv.y) * ratio / 300. * (vel.x + vel.y) / 7.; uv.y -= sin(uv.x) * ratio / 300. * (vel.x + vel.y) / 7.; tex1.b = texture2D(u_texture, centeredAspectRatio(uv, u_textureFactor )).b; tex2.b = texture2D(u_texture2, centeredAspectRatio(uv, u_textureFactor )).b;

      Basically it’s taking the texture coordinates and modifying them slightly differently for each channel, then combining them at the end.

  2. I downloaded the source files, but it seems the project doesnt run. (yes i have it on a server)

  3. I have very good knowledge of CSS,HTML,Javascript and i’ve learned a basics of Three.js , but this tutorials is too difficult for me. Can someone give me a some advice how to start learning more in web design to create such cool things?

  4. Really great work. Quite fluid and easy to use, on any device.

    There are a few prevailing issues however… when using an iPad and/or Smartphone there is no way to click and navigate to links of any type. And, the pressing of a link (on any device/desktop) still triggers the “stickiness” of the interface. Is there a way to avoid this? Perhaps by disabling the interface effect when hovering over a link? Also, on the iPad, in landscape orientation a small gap can be seen at the top area of the viewport. My guess is the “follow-me cursor” is making this somehow?

    Thank you for any insight into these points!