From our sponsor: Ready to show your plugin skills? Enter the Penpot Plugins Contest (Nov 15-Dec 15) to win cash prizes!
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)
}
}
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:
- 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
Opening the source file in browser doesn’t work. I’m new to this. How do you make it work? Thank you.
The source files are available on github as well https://github.com/clementroche/motion-hover-effects
you need build web server environment