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:

Distortion Image Effect (main effect) 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) } }

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:

uTexture contains the texture data to display the image on the plane uOffset provides plane deformation values 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:

when we hover over an item, the plane’s texture takes the item’s texture when the mouse moves on the container, the plane’s position follows the mouse and its vertices are deformed when the mouse leaves the container, the plane’s opacity fades to 0 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.

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.

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

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

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

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

Credits

Art Direction, Photography, Dev (HTML,CSS) – Niccolò Miranda

Dev (JS, WebGL) – Clément Roche