Hover list with a bending WebGL preview

A project list where the image follows the cursor. Built with the DOM first, then rebuilt in WebGL so the lag bends the image.

Source on GitHub
Hover the list. Desktop with a mouse only.Open full screen

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);
});

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,
  });
}

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 },
  );
}

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.

The dom branch. Same list, no WebGL.Open full screen

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:

  1. The DOM stays in charge. GSAP keeps animating the follower. The plane just copies it.
  2. The lag becomes the bend. The distance between the cursor and the plane curves the vertices.
  3. 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),
);

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.

Push the distortion up to see the curve, then move the cursor fast.

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);
  • edge is the line between the two images. It moves with uProgress, from the bottom or from the top depending on uDirection.
  • bend curves 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.
  • showNext is 1 on 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.

Scrub the progress by hand to see the edge bend.

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 includes colorspace_fragment. Skip either and the images come out washed out or too dark compared to an <img>.
  • The material uses premultipliedAlpha: true and 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_LERP is 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