A list of projects with an image that follows the cursor. You’ve seen it on a hundred portfolios. This version adds two things the DOM can’t do: the image bends when it trails behind the cursor, and the swap between two projects happens behind a curved edge.
We’ll build it in three steps, one per branch of the repo:
| Branch | What it shows |
|---|---|
main |
The list itself, no effect |
dom |
The preview follows the cursor, images slide in plain DOM |
webgl |
Same behaviour, the image is now a WebGL plane that bends |
If you only need the follower, stop at step 2. It’s lighter, it ships faster, and it’s a perfectly good choice.
The repo is vanilla TypeScript. Where the code depends on the framework, the tabs also show it as a Vue component. Your choice applies to the whole page.
1. The list
The markup is a plain list of links. Nothing here knows about the effect.
<main class="works">
<h1 class="works__title">Four briefs,<br /><em>four answers</em></h1>
<ul class="works__list" data-list>
<li class="works__item">
<a href="#" class="works__link">
<span class="works__year">2025</span>
<span class="works__text">
<span class="works__name">Opaline</span>
<span class="works__client">Maison Verre</span>
</span>
<span class="works__cta">View project →</span>
</a>
</li>
<!-- ... -->
</ul>
</main>
Two CSS details do most of the work.
The first one dims every row except the hovered one, without a line of JavaScript:
.works__list:has(.works__item:hover) .works__item:not(:hover) .works__link {
opacity: 0.35;
}
The second one keeps the text readable once an image passes under it:
.works {
position: relative;
z-index: 1;
color: #fff;
mix-blend-mode: exclusion;
}
The text is white in the CSS, yet it renders dark. With exclusion, white inverts whatever sits below it: over the #fafafa background you get a near black, over the image you get its negative. So the text stays readable on any image, and you never have to pick a color per project.
2. The DOM version
The follower is two nested elements:
.follower → moves with the cursor (x, y)
.follower__inner → shows and hides (opacity, scale), clips its children
.follower__visual × 4 → one per project, slides in and out (yPercent)
Each level owns one kind of transform. The outer one moves, the inner one scales, the visuals slide. That way the tween that follows the cursor never fights the tween that shows the preview.
Following the cursor
gsap.quickTo creates a function that retargets the same tween on every call, instead of creating a new tween on every mousemove:
gsap.set(this.el, { xPercent: -50, yPercent: -50 });
this.xTo = gsap.quickTo(this.el, 'x', { duration: 0.6, ease: 'power3' });
this.yTo = gsap.quickTo(this.el, 'y', { duration: 0.6, ease: 'power3' });
window.addEventListener('mousemove', e => {
this.xTo(e.clientX);
this.yTo(e.clientY);
});<script setup lang="ts">
import gsap from 'gsap';
import { onBeforeUnmount, onMounted, ref } from 'vue';
const el = ref<HTMLElement>();
let xTo: gsap.QuickToFunc;
let yTo: gsap.QuickToFunc;
const onMouseMove = (e: MouseEvent) => {
xTo(e.clientX);
yTo(e.clientY);
};
onMounted(() => {
gsap.set(el.value!, { xPercent: -50, yPercent: -50 });
xTo = gsap.quickTo(el.value!, 'x', { duration: 0.6, ease: 'power3' });
yTo = gsap.quickTo(el.value!, 'y', { duration: 0.6, ease: 'power3' });
window.addEventListener('mousemove', onMouseMove);
});
onBeforeUnmount(() => {
window.removeEventListener('mousemove', onMouseMove);
gsap.killTweensOf(el.value!);
});
</script>
<template>
<div ref="el" class="follower" />
</template>The -50% centers the preview on the cursor. The 0.6s duration is the lag, and the lag is what we’ll turn into a bend in step 5. Keep it in mind.
Showing and hiding
Entering the list scales the preview up from 0.1, leaving scales it back down:
private toggle(visible: boolean) {
gsap.to(this.inner, {
opacity: visible ? 1 : 0,
scale: visible ? 1 : 0.1,
duration: 0.8,
ease: "expo",
overwrite: true,
});
}<script setup lang="ts">
import gsap from 'gsap';
import { ref, watch } from 'vue';
const props = defineProps<{ visible: boolean }>();
const inner = ref<HTMLElement>();
watch(
() => props.visible,
visible => {
gsap.to(inner.value!, {
opacity: visible ? 1 : 0,
scale: visible ? 1 : 0.1,
duration: 0.8,
ease: 'expo',
overwrite: true,
});
},
);
</script>
<template>
<div ref="inner" class="follower__inner">
<slot />
</div>
</template>overwrite: true matters: move in and out of the list quickly and the new tween takes over from wherever the previous one was, without a jump.
Sliding between projects
When the cursor moves to another row, the previous image leaves and the next one comes in. The direction depends on where you came from: going down the list, images slide up; going up, they slide down.
private onItemEnter(index: number) {
const forward = this.prevIndex === null || index > this.prevIndex;
const prev = this.prevIndex !== null ? this.visuals[this.prevIndex] : null;
const next = this.visuals[index];
this.prevIndex = index;
if (prev && prev !== next) {
gsap.to(prev, {
yPercent: forward ? -100 : 100,
duration: 0.5,
ease: "power2.inOut",
overwrite: true,
});
}
if (this.firstEntry) {
this.firstEntry = false;
gsap.set(next, { yPercent: 0, overwrite: true });
return;
}
gsap.fromTo(
next,
{ yPercent: forward ? 100 : -100 },
{ yPercent: 0, duration: 0.5, ease: "power2.inOut", overwrite: true },
);
}<script setup lang="ts">
import gsap from 'gsap';
import { watch } from 'vue';
const props = defineProps<{ images: string[]; activeIndex: number | null }>();
// Filled by index: a ref array on a v-for doesn't guarantee the order
const visuals: HTMLElement[] = [];
let firstEntry = true;
watch(
() => props.activeIndex,
(index, prevIndex) => {
if (index === null) {
firstEntry = true;
return;
}
const forward = prevIndex === null || index > prevIndex;
const prev = prevIndex !== null ? visuals[prevIndex] : null;
const next = visuals[index];
if (prev && prev !== next) {
gsap.to(prev, {
yPercent: forward ? -100 : 100,
duration: 0.5,
ease: 'power2.inOut',
overwrite: true,
});
}
if (firstEntry) {
firstEntry = false;
gsap.set(next, { yPercent: 0, overwrite: true });
return;
}
gsap.fromTo(
next,
{ yPercent: forward ? 100 : -100 },
{ yPercent: 0, duration: 0.5, ease: 'power2.inOut', overwrite: true },
);
},
);
</script>
<template>
<div
v-for="(src, i) in images"
:key="src"
:ref="
el => {
if (el) visuals[i] = el as HTMLElement;
}
"
class="follower__visual"
>
<img :src="src" alt="" />
</div>
</template>firstEntry skips the slide when you enter the list: the preview is already scaling up, a second motion on top of it would be noise.
3. Why WebGL
The DOM can move an image, scale it, rotate it, clip it. It can’t bend it. CSS transforms, even matrix3d, keep straight lines straight. To curve an image you need to move points inside it, and that means a mesh with vertices you control.
So the webgl branch keeps the same list and the same behaviour, with three changes:
- The DOM stays in charge. GSAP keeps animating the follower. The plane just copies it.
- The lag becomes the bend. The distance between the cursor and the plane curves the vertices.
- The image swap bends. The next image comes in behind a curved edge.
Run git diff dom webgl in the repo to see exactly what changes.
4. The DOM stays in charge
This is the part most tutorials skip, and the one that keeps the effect smooth.
The images are gone from the DOM, but .follower and its inner element stay. They’re empty and invisible, and GSAP animates them exactly like before. The Follower class doesn’t change much: it exposes those two elements and calls back when a row is hovered.
const follower = new Follower(list, (index, forward, instant) =>
gl.show(index, forward, instant),
);
const gl = new FollowerGL(
follower,
projects.map(p => p.image),
);<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue';
import { projects } from './data';
import Follower from './Follower.vue';
import { FollowerGL } from './webgl/FollowerGL';
// Follower.vue exposes its two elements: defineExpose({ el, proxy })
const follower = ref<InstanceType<typeof Follower>>();
let gl: FollowerGL;
onMounted(() => {
gl = new FollowerGL(
follower.value!,
projects.map(p => p.image),
);
});
// The destroy() from step 7
onBeforeUnmount(() => gl.destroy());
</script>
<template>
<Follower
ref="follower"
@select="(index, forward, instant) => gl.show(index, forward, instant)"
/>
</template>FollowerGL stays a plain class in both versions. It only needs two elements to read from, so nothing in it knows about Vue.
On every frame, the WebGL side reads the values GSAP is animating and copies them onto the plane:
const centerX = Number(gsap.getProperty(el, 'x'));
const centerY = Number(gsap.getProperty(el, 'y'));
const scale = Number(gsap.getProperty(proxy, 'scale'));
const alpha = Number(gsap.getProperty(proxy, 'opacity'));
this.mesh.position.set(
centerX - window.innerWidth / 2,
window.innerHeight / 2 - centerY,
0,
);
this.mesh.scale.set(this.size.x * scale, this.size.y * scale, 1);
gsap.getProperty returns the value GSAP has cached for the element. It doesn’t ask the browser for the layout, so nothing forces a reflow.
The camera makes the conversion easy. It’s orthographic and matches the viewport, so one unit in the scene is one CSS pixel:
this.camera.left = -width / 2;
this.camera.right = width / 2;
this.camera.top = height / 2;
this.camera.bottom = -height / 2;
The only thing to handle is the origin. The DOM counts from the top left with y going down, the scene counts from the center with y going up. Hence centerX - innerWidth / 2 and innerHeight / 2 - centerY.
The plane itself is a 1×1 geometry scaled to the size of the follower in pixels. And when the preview is hidden, the loop stops rendering:
if (alpha <= 0.001) {
if (this.wasVisible) {
this.renderer.clear();
this.wasVisible = false;
}
return;
}
5. The lag becomes the bend
Remember the 0.6s lag of the follower. The cursor is where the mouse is, the plane is where GSAP has brought it so far. The gap between the two is the lag, and it’s exactly what we want to show:
this.offset.x = gsap.utils.interpolate(
this.offset.x,
(this.mouse.x - centerX) * DISTORTION,
DISTORTION_LERP,
);
this.offset.y = gsap.utils.interpolate(
this.offset.y,
-(this.mouse.y - centerY) * DISTORTION,
DISTORTION_LERP,
);
DISTORTION is 0.0008. The plane is 1×1 before scaling, so the offset is a fraction of its size: a 200px lag gives 0.16, so the middle of the image moves by 16% of its width. The y is flipped for the same reason as before. The lerp smooths the value so the bend settles instead of snapping.
The vertex shader applies it:
vec3 deformationCurve(vec3 position, vec2 uv, vec2 offset) {
position.x += sin(uv.y * PI) * offset.x;
position.y += sin(uv.x * PI) * offset.y;
return position;
}
sin(uv * PI) is 0 on the edges and 1 in the middle. The middle of the plane moves by the full offset, the edges don’t move at all, and everything in between follows a curve. Move the cursor right, the image bows to the right.
This only works because the plane has vertices to move. PlaneGeometry(1, 1, 32, 32) gives a 32×32 grid. With the default single segment you’d have four corners and nothing to bend.
6. The curved swap
The DOM version slid two images inside a clipped box. The shader does the same thing with two textures, uTexturePrev and uTextureNext, and one uProgress going from 0 to 1 in 0.5s.
First, a line that crosses the plane, with a bulge in the middle:
float forward = step(0.0, uDirection);
float bend = sin(vUv.x * PI) * sin(uProgress * PI) * uCurve;
float edge = mix(1.0 - uProgress, uProgress, forward) + bend * uDirection;
float showNext = mix(step(edge, vUv.y), 1.0 - step(edge, vUv.y), forward);
edgeis the line between the two images. It moves withuProgress, from the bottom or from the top depending onuDirection.bendcurves it.sin(vUv.x * PI)makes it bulge in the middle,sin(uProgress * PI)makes the bulge grow then shrink, so the line is straight at the start and at the end.showNextis1on the side of the line where the next image is.
Then both images slide, like the DOM version:
vec2 nextUv = vec2(vUv.x, vUv.y + uDirection * (1.0 - uProgress));
vec2 prevUv = vec2(vUv.x, vUv.y - uDirection * uProgress);
Going down the list, the next image comes up from below while the previous one leaves through the top. Same motion as step 2, with a curved edge instead of a straight one.
object-fit: cover, in a shader
A texture fills the plane by default, whatever its ratio. coverUv crops it the way object-fit: cover would:
vec2 coverUv(vec2 uv, vec2 imageRes, vec2 planeRes) {
float planeRatio = planeRes.x / planeRes.y;
float imageRatio = imageRes.x / imageRes.y;
vec2 scale = planeRatio < imageRatio
? vec2(planeRatio / imageRatio, 1.0)
: vec2(1.0, imageRatio / planeRatio);
return (uv - 0.5) * scale + 0.5;
}
The image size comes from the texture once it’s loaded, the plane size is updated every frame since it scales.
Hovering fast
Run your cursor down the list quickly and every swap gets interrupted by the next one. show() kills the running transition and decides which image becomes the new “previous”:
const from = uniforms.uProgress.value > 0.5 ? this.nextIndex : this.prevIndex;
Whichever image covers most of the plane at that moment stays. Without this line, a fast hover makes the preview jump back to an image that was already halfway out.
Colors and fade
Two details keep the WebGL version looking like the DOM one:
- The textures use
THREE.SRGBColorSpace, and the fragment shader includescolorspace_fragment. Skip either and the images come out washed out or too dark compared to an<img>. - The material uses
premultipliedAlpha: trueand the shader multiplies the color by the alpha at the end. That’s what lets the preview fade out cleanly, without dark edges.
7. Going to production
The demo is built to be read. Before shipping it on a client site, there’s more to handle:
-
A frame-rate independent lerp.
DISTORTION_LERPis applied once per frame, so on a 120Hz screen the bend catches up twice as fast and looks flatter. Scale it with the ticker:const t = 1 - Math.pow(1 - DISTORTION_LERP, gsap.ticker.deltaRatio(60)); -
A
destroy()method. Remove the listeners, take the render off the ticker, dispose of the geometry, material, textures and renderer. Without it, page transitions leak a WebGL context every time. -
Listening to the media query. The effect checks
(hover: hover) and (min-width: 769px)once on load. Resize across 769px and it should start or stop. -
prefers-reduced-motion. Keep the follower, drop the bend and the curved swap. -
Loading textures on demand. Four images are fine up front. Twenty aren’t: load them when the cursor enters the list.
Source
The full code is on GitHub, one branch per step. Start from main and build it yourself, or jump straight to webgl.
git clone https://github.com/davidfaure/hover-list-webgl
cd hover-list-webgl
npm install
git checkout webgl
npm run dev