How to Create Motion Hover Effects with Image Distortions using Three.js

In this tutorial you will learn how to achieve interesting looking hover effects with image distortions using Three.js.

The reveal hover effect on images has become a very popular pattern in modern websites. It plays an important role in taking the user experience to a higher level. But usually these kind of animations remain too “flat”. Natural movements with a realistic feel are much more enjoyable for the user. In this tutorial we’re going to build some special interactive reveal effects for images when a link is hovered. The aim is to add fluid and interesting motion to the effects. We will be exploring three different types of animations. This dynamic experience consists of two parts:

  1. Distortion Image Effect (main effect)
  2. RGB Displacement, Image Trail Effect, Image Stretch (additional effects)

We assume that you are confident with JavaScript and have some basic understanding of Three.js and WebGL.

Getting started

The markup for this effect will include a link element that contains an image (and some other elements that are not of importance for our effect):

<a class="link" href="#">
	<!-- ... -->
	<img src="img/demo1/img1.jpg" alt="Some image" />
</a>

The EffectShell class will group common methods and properties of the three distinct effects we’ll be creating. As a result, each effect will extend EffectShell.

Three.js setup

First of all, we need to create the Three.js scene.

class EffectShell {
 constructor(container = document.body, itemsWrapper = null) {
   this.container = container
   this.itemsWrapper = itemsWrapper
   if (!this.container || !this.itemsWrapper) return
   this.setup()
 }
 
 setup() {
   window.addEventListener('resize', this.onWindowResize.bind(this), false)
 
   // renderer
   this.renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
   this.renderer.setSize(this.viewport.width, this.viewport.height)
   this.renderer.setPixelRatio = window.devicePixelRatio
   this.container.appendChild(this.renderer.domElement)
 
   // scene
   this.scene = new THREE.Scene()
 
   // camera
   this.camera = new THREE.PerspectiveCamera(
     40,
     this.viewport.aspectRatio,
     0.1,
     100
   )
   this.camera.position.set(0, 0, 3)
 
   // animation loop
   this.renderer.setAnimationLoop(this.render.bind(this))
 }
 
 render() {
   // called every frame
   this.renderer.render(this.scene, this.camera)
 }
 
 get viewport() {
   let width = this.container.clientWidth
   let height = this.container.clientHeight
   let aspectRatio = width / height
   return {
     width,
     height,
     aspectRatio
   }
 }
 
 onWindowResize() {
   this.camera.aspect = this.viewport.aspectRatio
   this.camera.updateProjectionMatrix()
   this.renderer.setSize(this.viewport.width, this.viewport.height)
 }
}

Tiny break: 📬 Want to stay up to date with frontend and trends in web design? Subscribe and get our Collective newsletter twice a tweek.

Get items and load textures

In our markup we have links with images inside. The next step is to get each link from the DOM and put them in an array.

class EffectShell {
 ...
 get itemsElements() {
   // convert NodeList to Array
   const items = [...this.itemsWrapper.querySelectorAll('.link')]
 
   //create Array of items including element, image and index
   return items.map((item, index) => ({
     element: item,
     img: item.querySelector('img') || null,
     index: index
   }))
 }
}

Because we will use the images as a texture, we have to load the textures through Three.js’ TextureLoader. It’s an asynchronous operation so we shouldn’t initialize the effect without all textures being loaded. Otherwise our texture will be fully black. That’s why we use Promises here:

class EffectShell {
 ...
 initEffectShell() {
   let promises = []
 
   this.items = this.itemsElements
 
   const THREEtextureLoader = new THREE.TextureLoader()
   this.items.forEach((item, index) => {
     // create textures
     promises.push(
       this.loadTexture(
         THREEtextureLoader,
         item.img ? item.img.src : null,
         index
       )
     )
   })
 
   return new Promise((resolve, reject) => {
     // resolve textures promises
     Promise.all(promises).then(promises => {
       // all textures are loaded
       promises.forEach((promise, index) => {
         // assign texture to item
         this.items[index].texture = promise.texture
       })
       resolve()
     })
   })
 }
 
 loadTexture(loader, url, index) {
   // https://threejs.org/docs/#api/en/loaders/TextureLoader
   return new Promise((resolve, reject) => {
     if (!url) {
       resolve({ texture: null, index })
       return
     }
     // load a resource
     loader.load(
       // resource URL
       url,
 
       // onLoad callback
       texture => {
         resolve({ texture, index })
       },
 
       // onProgress callback currently not supported
       undefined,
 
       // onError callback
       error => {
         console.error('An error happened.', error)
         reject(error)
       }
     )
   })
 }
}


At this point we get an array of items. Each item contains an Element, Image, Index and Texture. Then, when all textures are loaded we can initialize the effect.

class EffectShell {
 constructor(container = document.body, itemsWrapper = null) {
   this.container = container
   this.itemsWrapper = itemsWrapper
   if (!this.container || !this.itemsWrapper) return
   this.setup()
   this.initEffectShell().then(() => {
     console.log('load finished')
     this.isLoaded = true
   })
 }
 ...
}

Create the plane

Once we have created the scene and loaded the textures, we can create the main effect. We start by creating a plane mesh using PlaneBufferGeometry and ShaderMaterial with three uniforms:

  1. uTexture contains the texture data to display the image on the plane
  2. uOffset provides plane deformation values
  3. uAlpha manages plane opacity
class Effect extends EffectShell {
 constructor(container = document.body, itemsWrapper = null, options = {}) {
   super(container, itemsWrapper)
   if (!this.container || !this.itemsWrapper) return
 
   options.strength = options.strength || 0.25
   this.options = options
 
   this.init()
 }
 
 init() {
   this.position = new THREE.Vector3(0, 0, 0)
   this.scale = new THREE.Vector3(1, 1, 1)
   this.geometry = new THREE.PlaneBufferGeometry(1, 1, 32, 32)
   this.uniforms = {
     uTexture: {
       //texture data
       value: null
     },
     uOffset: {
       //distortion strength
       value: new THREE.Vector2(0.0, 0.0)
     },
     uAlpha: {
       //opacity
       value: 0
     }
 
   }
   this.material = new THREE.ShaderMaterial({
     uniforms: this.uniforms,
     vertexShader: `
       uniform vec2 uOffset;
       varying vec2 vUv;
 
       void main() {
         vUv = uv;
         vec3 newPosition = position;
         gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
       }
     `,
     fragmentShader: `
       uniform sampler2D uTexture;
       uniform float uAlpha;
       varying vec2 vUv;
 
       void main() {
         vec3 color = texture2D(uTexture,vUv).rgb;
         gl_FragColor = vec4(color,1.0);
       }
     `,
     transparent: true
   })
   this.plane = new THREE.Mesh(this.geometry, this.material)
   this.scene.add(this.plane)
 }
}

At this point, we have a black squared plane in the center of our screen. Not very impressive.

Adding interactions

Creating events

So, let’s outline all our possible events and what needs to be done:

  1. when we hover over an item, the plane’s texture takes the item’s texture
  2. when the mouse moves on the container, the plane’s position follows the mouse and its vertices are deformed
  3. when the mouse leaves the container, the plane’s opacity fades to 0
  4. when the mouse hovers a link, if the plane was invisible, its opacity animates to 1
class EffectShell {
 constructor(container = document.body, itemsWrapper = null) {
   this.container = container
   this.itemsWrapper = itemsWrapper
   if (!this.container || !this.itemsWrapper) return
 
   this.setup()
   this.initEffectShell().then(() => {
     console.log('load finished')
     this.isLoaded = true
   })
   this.createEventsListeners()
 }
 ...
 createEventsListeners() {
   this.items.forEach((item, index) => {
     item.element.addEventListener(
       'mouseover',
       this._onMouseOver.bind(this, index),
       false
     )
   })
 
   this.container.addEventListener(
     'mousemove',
     this._onMouseMove.bind(this),
     false
   )
   this.itemsWrapper.addEventListener(
     'mouseleave',
     this._onMouseLeave.bind(this),
     false
   )
 }
 
 _onMouseLeave(event) {
   this.isMouseOver = false
   this.onMouseLeave(event)
 }
 
 _onMouseMove(event) {
   // get normalized mouse position on viewport
   this.mouse.x = (event.clientX / this.viewport.width) * 2 - 1
   this.mouse.y = -(event.clientY / this.viewport.height) * 2 + 1
 
   this.onMouseMove(event)
 }
 
 _onMouseOver(index, event) {
   this.onMouseOver(index, event)
 }
}

Updating the texture

When we created the plane geometry we gave it 1 as height and width, that’s why our plane is always squared. But we need to scale the plane in order to fit the image dimensions otherwise the texture will be stretched.

class Effect extends EffectShell {
 ...
 onMouseEnter() {}
 
 onMouseOver(index, e) {
   if (!this.isLoaded) return
   this.onMouseEnter()
   if (this.currentItem && this.currentItem.index === index) return
   this.onTargetChange(index)
 }
 
 onTargetChange(index) {
   // item target changed
   this.currentItem = this.items[index]
   if (!this.currentItem.texture) return
 
   //update texture
   this.uniforms.uTexture.value = this.currentItem.texture
 
   // compute image ratio
   let imageRatio =
     this.currentItem.img.naturalWidth / this.currentItem.img.naturalHeight
 
   // scale plane to fit image dimensions
   this.scale = new THREE.Vector3(imageRatio, 1, 1)
   this.plane.scale.copy(this.scale)
 }
}

Updating the plane position

Here comes the first mathematical part of this tutorial. As we move the mouse over the viewport, the browser gives us the mouse’s 2D coordinates from the viewport, but what we need is the 3D coordinates in order to move our plane in the scene. So, we need to remap the mouse coordinate to the view size of our scene.

First, we need to get the view size of our scene. For this, we can compute the plane’s fit-to-screen dimensions by resolving AAS triangles using the camera position and camera FOV. This solution is provided by ayamflow.

class EffectShell {
 ...
 get viewSize() {
   // https://gist.github.com/ayamflow/96a1f554c3f88eef2f9d0024fc42940f
 
   let distance = this.camera.position.z
   let vFov = (this.camera.fov * Math.PI) / 180
   let height = 2 * Math.tan(vFov / 2) * distance
   let width = height * this.viewport.aspectRatio
   return { width, height, vFov }
 }
}

We are going to remap the normalized mouse position with the scene view dimensions using a value mapping function.

Number.prototype.map = function(in_min, in_max, out_min, out_max) {
 return ((this - in_min) * (out_max - out_min)) / (in_max - in_min) + out_min
}

Finally, we will add a GSAP-powered animation in order to smooth out our movements.

class Effect extends EffectShell {
 ...
 onMouseMove(event) {
   // project mouse position to world coordinates
   let x = this.mouse.x.map(
     -1,
     1,
     -this.viewSize.width / 2,
     this.viewSize.width / 2
   )
   let y = this.mouse.y.map(
     -1,
     1,
     -this.viewSize.height / 2,
     this.viewSize.height / 2
   )
 
   // update plane position
   this.position = new THREE.Vector3(x, y, 0)
   TweenLite.to(this.plane.position, 1, {
     x: x,
     y: y,
     ease: Power4.easeOut,
     onUpdate: this.onPositionUpdate.bind(this)
   })
 }
}

Fading the opacity

class Effect extends EffectShell {
 ...
 onMouseEnter() {
   if (!this.currentItem || !this.isMouseOver) {
     this.isMouseOver = true
     // show plane
     TweenLite.to(this.uniforms.uAlpha, 0.5, {
       value: 1,
       ease: Power4.easeOut
     })
   }
 }
 
 onMouseLeave(event) {
   TweenLite.to(this.uniforms.uAlpha, 0.5, {
     value: 0,
     ease: Power4.easeOut
   })
 }
}

Once correctly animated, we have to put uAlpha as alpha channel inside fragment shader of the plane material.


fragmentShader: `
 uniform sampler2D uTexture;
 uniform float uAlpha;
 varying vec2 vUv;

 void main() {
   vec3 color = texture2D(uTexture,vUv).rgb;
   gl_FragColor = vec4(color,uAlpha);
 }
`,

Adding the curved, velocity-sensitive distortion effect

During the movement animation, we compute the plane’s velocity and use it as uOffset for our distortion effect.

vector

class Effect extends EffectShell {
 ...
 onPositionUpdate() {
   // compute offset
   let offset = this.plane.position
     .clone()
     .sub(this.position) // velocity
     .multiplyScalar(-this.options.strength)
   this.uniforms.uOffset.value = offset
 }
}

Now, in order to make the “curved” distortion we will use the sine function. As you can see, the sine function is wave-shaped (sinusoidal) between x = 0 and x = PI. Moreover, the plane’s UVs are mapped between 0 and 1 so by multiplying uv by we can remap between 0 and PI. Then we multiply it by the uOffset value that we calculated beforehand and we get the curve distortion thanks to the velocity.

sine

vertexShader: `
 uniform vec2 uOffset;
 varying vec2 vUv;

 #define M_PI 3.1415926535897932384626433832795

 vec3 deformationCurve(vec3 position, vec2 uv, vec2 offset) {
   position.x = position.x + (sin(uv.y * M_PI) * offset.x);
   position.y = position.y + (sin(uv.x * M_PI) * offset.y);
   return position;
 }

 void main() {
   vUv = uv;
   vec3 newPosition = deformationCurve(position, uv, uOffset);
   gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
 }
`,

Additional effects

RGBShift

demo1

To do an RGB shift we have to separate the red channel from other channels and apply its offset:

fragmentShader: `
 uniform sampler2D uTexture;
 uniform float uAlpha;
 uniform vec2 uOffset;

 varying vec2 vUv;

 vec3 rgbShift(sampler2D texture, vec2 uv, vec2 offset) {
   float r = texture2D(uTexture,vUv + uOffset).r;
   vec2 gb = texture2D(uTexture,vUv).gb;
   return vec3(r,gb);
 }

 void main() {
   vec3 color = rgbShift(uTexture,vUv,uOffset);
   gl_FragColor = vec4(color,uAlpha);
 }
`,

Stretch

demo3

By offsetting UV with the uOffset values we can achieve a “stretch effect”, but in order to avoid that the texture border gets totally stretched we need to scale the UVs.


vertexShader: `
 uniform vec2 uOffset;

 varying vec2 vUv;

 vec3 deformationCurve(vec3 position, vec2 uv, vec2 offset) {
   float M_PI = 3.1415926535897932384626433832795;
   position.x = position.x + (sin(uv.y * M_PI) * offset.x);
   position.y = position.y + (sin(uv.x * M_PI) * offset.y);
   return position;
 }

 void main() {
   vUv =  uv + (uOffset * 2.);
   vec3 newPosition = position;
   newPosition = deformationCurve(position,uv,uOffset);
   gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
 }
`,
fragmentShader: `
 uniform sampler2D uTexture;
 uniform float uAlpha;

 varying vec2 vUv;

 // zoom on texture 
 vec2 scaleUV(vec2 uv,float scale) {
   float center = 0.5;
   return ((uv - center) * scale) + center;
 }

 void main() {
   vec3 color = texture2D(uTexture,scaleUV(vUv,0.8)).rgb;
   gl_FragColor = vec4(color,uAlpha);
 }
`,

Trails

demo2

To make a trail-like effect, we have to use several planes with the same texture but with a different position animation duration.

class TrailsEffect extends EffectShell {
 ...
 init() {
   this.position = new THREE.Vector3(0, 0, 0)
   this.scale = new THREE.Vector3(1, 1, 1)
   this.geometry = new THREE.PlaneBufferGeometry(1, 1, 16, 16)
   //shared uniforms
   this.uniforms = {
     uTime: {
       value: 0
     },
     uTexture: {
       value: null
     },
     uOffset: {
       value: new THREE.Vector2(0.0, 0.0)
     },
     uAlpha: {
       value: 0
     }
   }
   this.material = new THREE.ShaderMaterial({
     uniforms: this.uniforms,
     vertexShader: `
       uniform vec2 uOffset;
 
       varying vec2 vUv;
 
       vec3 deformationCurve(vec3 position, vec2 uv, vec2 offset) {
         float M_PI = 3.1415926535897932384626433832795;
         position.x = position.x + (sin(uv.y * M_PI) * offset.x);
         position.y = position.y + (sin(uv.x * M_PI) * offset.y);
         return position;
       }
 
       void main() {
         vUv = uv;
         vec3 newPosition = position;
         newPosition = deformationCurve(position,uv,uOffset);
         gl_Position = projectionMatrix * modelViewMatrix * vec4( newPosition, 1.0 );
       }
     `,
     fragmentShader: `
       uniform sampler2D uTexture;
       uniform float uAlpha;
       uniform vec2 uOffset;
 
       varying vec2 vUv;
 
       void main() {
         vec3 color = texture2D(uTexture,vUv).rgb;
         gl_FragColor = vec4(color,uAlpha);
       }
     `,
     transparent: true
   })
   this.plane = new THREE.Mesh(this.geometry, this.material)
 
   this.trails = []
   for (let i = 0; i < this.options.amount; i++) {
     let plane = this.plane.clone()
     this.trails.push(plane)
     this.scene.add(plane)
   }
 }
 
 onMouseMove(event) {
   // project mouse position to world coodinates
   let x = this.mouse.x.map(
     -1,
     1,
     -this.viewSize.width / 2,
     this.viewSize.width / 2
   )
   let y = this.mouse.y.map(
     -1,
     1,
     -this.viewSize.height / 2,
     this.viewSize.height / 2
   )
 
   TweenLite.to(this.position, 1, {
     x: x,
     y: y,
     ease: Power4.easeOut,
     onUpdate: () => {
       // compute offset
       let offset = this.position
         .clone()
         .sub(new THREE.Vector3(x, y, 0))
         .multiplyScalar(-this.options.strength)
       this.uniforms.uOffset.value = offset
     }
   })
 
   this.trails.forEach((trail, index) => {
     let duration =
       this.options.duration * this.options.amount -
       this.options.duration * index
     TweenLite.to(trail.position, duration, {
       x: x,
       y: y,
       ease: Power4.easeOut
     })
   })
 }
}

Conclusion

We have tried to make this tutorial as easy as possible to follow, so that it’s understandable to those who are not as advanced in Three.js. If there’s anything you have not understood, please feel free to comment below.

The main purpose of this tutorial was to show how to create motion-distortion effects on images, but you can play around with the base effect and add something else or try something different. Feel free to make pull requests or open an issue in our GitHub repo.
These effects can also fit very well with texture transitions; it’s something you can explore with GL Transitions.

We hope you enjoyed this article and play around with this to explore new stuff.

References

  • Three.js
  • GSAP
  • Fit Plane to screen
  • Credits

    Art Direction, Photography, Dev (HTML,CSS) – Niccolò Miranda
    Dev (JS, WebGL) – Clément Roche

    Niccolò Miranda

    Niccolò Miranda is a freelance creative director, designer and developer with a focus on interaction.

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

    Fresh news, inspo, code demos, and UI animations—zero fluff, all quality. Make your Mondays and Thursdays creative!

    Feedback 3

    Comments are closed.
    1. Opening the source file in browser doesn’t work. I’m new to this. How do you make it work? Thank you.