All components

Turbulent Inversion Lens Hover Effect

WebGL animation component · Published 2026-07-27 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

A full-viewport Three.js plane renders a portrait through a fragment shader; moving the cursor drives a lerped mask whose radius grows on hover, and inside that circular lens the image is converted to inverted grayscale with animated fBm turbulence jittering the mask edge.

How it's built

Categoryhover
Techthree
Complexitysection
Performance costheavy
Mobile-safedesktop-first

three webgl shader hover cursor invert turbulence lens grayscale

Rebuild it with AI

To reproduce this animation in your own project, copy the prompt below into Claude Code, Cursor or any AI coding agent. The prompt is validated — it describes the exact structure, timing and easing, so the agent rebuilds the effect faithfully and you can then adapt colors, copy and layout to your design.

The full prompt

Turbulent Inversion Lens Hover Effect

Goal

Build a full-viewport WebGL image viewer with a "turbulent inversion lens". A single photograph fills the screen, rendered on a Three.js fullscreen quad through a custom fragment shader. The star effect: as the cursor moves over the image, a circular lens trails the mouse with a smooth lag, and everything inside that circle is shown as inverted grayscale (a photographic-negative look). The lens edge is not a clean circle — it is jittered by animated fractal turbulence (fBm noise), so the boundary constantly boils and crackles like static. The lens radius grows open when the cursor enters the container and shrinks closed to nothing when it leaves (or when the section scrolls out of view). No scroll effects, no clicks — just cursor-driven shading.

Tech

Vanilla HTML/CSS/JS with ES module imports. No GSAP, no Lenis. The only runtime dependency is three (npm). All motion comes from a requestAnimationFrame loop that lerps values into shader uniforms.

Imports needed:

  • three (import * as THREE from "three")
  • A local ./shaders.js module exporting two GLSL strings: vertexShader and fragmentShader (given verbatim below — they are load-bearing).

Layout / HTML

Minimal. One full-viewport container holding a hidden <img>; the WebGL canvas is created in JS and appended into the container.

<div class="inversion-lens">
  <img src="<path-to-image>" alt="" />
</div>
<script type="module" src="./script.js"></script>

The <img> is never displayed — JS reads its src and loads it as a texture. The JS must support multiple .inversion-lens containers: document.querySelectorAll(".inversion-lens").forEach(initHoverEffect).

Styling

Tiny stylesheet — all visuals come from WebGL.

  • Global reset: * { margin:0; padding:0; box-sizing:border-box; }
  • .inversion-lens { position:relative; width:100vw; height:100svh; overflow:hidden; }
  • .inversion-lens img { display:none; }

No fonts, no text, no other elements.

Core effect (be exhaustive — this is the whole component)

Config (exact values)
const config = {
  maskRadius: 0.15,          // lens radius in UV space when hovering
  maskSpeed: 0.75,           // multiplier on u_time for the turbulence scroll
  lerpFactor: 0.05,          // mouse-follow lerp per frame
  radiusLerpSpeed: 0.1,      // radius open/close lerp per frame
  turbulenceIntensity: 0.075 // amplitude of the edge jitter
};
Per-container state
  • targetMouse = new THREE.Vector2(0.5, 0.5) and lerpedMouse = new THREE.Vector2(0.5, 0.5) — pointer position in UV space (0–1, y flipped).
  • targetRadius = 0.0 — desired lens radius (0 when closed, config.maskRadius when hovering).
  • isInView, isMouseInsideContainer, lastMouseX, lastMouseY trackers.
Texture load → scene setup

Use new THREE.TextureLoader().load(img.src, callback). In the callback:

  1. imageAspect = texture.image.width / texture.image.height — passed to the shader for cover-fit cropping.
  2. Texture quality: texture.minFilter = THREE.LinearMipMapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.anisotropy = 16;
  3. scene = new THREE.Scene(); camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1) (identity fullscreen camera — the vertex shader ignores matrices anyway).
  4. Uniforms (exact):

``js uniforms = { u_texture: { value: texture }, u_mouse: { value: new THREE.Vector2(0.5, 0.5) }, u_time: { value: 0.0 }, u_resolution: { value: new THREE.Vector2(containerW, containerH) }, u_radius: { value: 0.0 }, // starts closed u_speed: { value: config.maskSpeed }, // 0.75 u_imageAspect: { value: imageAspect }, u_turbulenceIntensity:{ value: config.turbulenceIntensity }, // 0.075 }; ``

  1. Mesh: new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader })), added to the scene.
  2. Renderer: new THREE.WebGLRenderer({ antialias: true }), setPixelRatio(window.devicePixelRatio), setSize(container.clientWidth, container.clientHeight), then append renderer.domElement into the container. (No resize handler — the canvas keeps its initial size.)
Pointer / visibility tracking
  • document.addEventListener("mousemove", ...) (on document, not the container) → call updateCursorState(e.clientX, e.clientY).
  • window.addEventListener("scroll", ...) → re-run updateCursorState(lastMouseX, lastMouseY) so the lens tracks correctly while the page scrolls under a stationary cursor.
  • An IntersectionObserver on the container with { threshold: 0.1 }: when the container leaves the viewport set targetRadius = 0.0 (lens closes).

updateCursorState(x, y):

  • Store lastMouseX/Y, get container.getBoundingClientRect() and test whether the point is inside the rect.
  • If inside: targetMouse.x = (x - rect.left) / rect.width; targetMouse.y = 1.0 - (y - rect.top) / rect.height; (flip Y into UV space) and targetRadius = config.maskRadius (0.15).
  • If outside: targetRadius = 0.0.
The rAF loop (the actual "animation")

Every frame:

lerpedMouse.lerp(targetMouse, 0.05);                       // lens center trails the cursor
uniforms.u_mouse.value.copy(lerpedMouse);
uniforms.u_time.value += 0.01;                             // drives turbulence scroll
uniforms.u_radius.value += (targetRadius - uniforms.u_radius.value) * 0.1; // radius eases open/closed
renderer.render(scene, camera);
  • Mouse lerp factor 0.05 → noticeable elastic lag behind the cursor.
  • Radius lerp factor 0.1 → the circle grows from 0 to 0.15 on enter and collapses back to 0 on leave, both eased exponentially.
  • u_time advances a fixed 0.01 per frame; the shader multiplies it by u_speed (0.75) to scroll the noise field, which makes the lens edge boil continuously even when the cursor is still.
Shaders (shaders.js — exact GLSL, load-bearing)
export const vertexShader = `
  varying vec2 v_uv;
  void main() {
    v_uv = uv;
    gl_Position = vec4(position, 1.0);
  }
`;

export const fragmentShader = `
  precision highp float;

  uniform sampler2D u_texture;
  uniform vec2 u_mouse;
  uniform float u_time;
  uniform vec2 u_resolution;
  uniform float u_radius;
  uniform float u_speed;
  uniform float u_imageAspect;
  uniform float u_turbulenceIntensity;

  varying vec2 v_uv;

  float hash(vec2 p) {
    return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
  }

  float noise(vec2 p) {
    vec2 i = floor(p);
    vec2 f = fract(p);
    vec2 u = f * f * (3.0 - 2.0 * f);
    return mix(
      mix(hash(i + vec2(0.0, 0.0)), hash(i + vec2(1.0, 0.0)), u.x),
      mix(hash(i + vec2(0.0, 1.0)), hash(i + vec2(1.0, 1.0)), u.x),
      u.y
    );
  }

  float turbulence(vec2 p) {
    float t = 0.0;
    float w = 0.5;
    for (int i = 0; i < 8; i++) {
      t += abs(noise(p)) * w;
      p *= 2.0;
      w *= 0.5;
    }
    return t;
  }

  void main() {
    vec2 uv = v_uv;
    float screenAspect = u_resolution.x / u_resolution.y;
    float ratio = u_imageAspect / screenAspect;

    vec2 texCoord = vec2(
      mix(0.5 - 0.5 / ratio, 0.5 + 0.5 / ratio, uv.x),
      uv.y
    );

    vec4 tex = texture2D(u_texture, texCoord);
    float gray = dot(tex.rgb, vec3(0.299, 0.587, 0.114));
    vec3 invertedGray = vec3(1.0 - gray);

    vec2 correctedUV = uv;
    correctedUV.x *= screenAspect;
    vec2 correctedMouse = u_mouse;
    correctedMouse.x *= screenAspect;

    float dist = distance(correctedUV, correctedMouse);

    float jaggedDist = dist + (turbulence(uv * 25.0 + u_time * u_speed) - 0.5) * u_turbulenceIntensity;

    float mask = step(jaggedDist, u_radius);

    vec3 finalColor = mix(invertedGray, tex.rgb, 1.0 - mask);
    gl_FragColor = vec4(finalColor, 1.0);
  }
`;

What the fragment shader does, step by step:

  • Cover-fit crop: ratio = imageAspect / screenAspect; the x texture coordinate is remapped with mix(0.5 - 0.5/ratio, 0.5 + 0.5/ratio, uv.x) so a wider-than-viewport image is center-cropped horizontally (like CSS object-fit: cover).
  • Negative look: luma via the Rec.601 weights (0.299, 0.587, 0.114), then inverted: vec3(1.0 - gray).
  • Aspect-corrected distance: both the fragment UV and the mouse UV have their x multiplied by screenAspect before distance(), so the lens is a true circle on any viewport.
  • Turbulent edge: jaggedDist = dist + (turbulence(uv * 25.0 + u_time * u_speed) - 0.5) * u_turbulenceIntensity — an 8-octave value-noise fBm (abs(noise), weight halving each octave) sampled at 25× UV frequency, scrolled by time, remapped to ±0.5 and scaled by 0.075, perturbs the distance field.
  • Hard mask: mask = step(jaggedDist, u_radius) — a binary inside/outside test (no soft edge; the jitter itself provides the texture).
  • Composite: mix(invertedGray, tex.rgb, 1.0 - mask) — inverted grayscale inside the lens, untouched photo outside.

Assets / images

One image only:

  • A single full-bleed photographic portrait in landscape orientation, roughly 3:2 (e.g. a studio portrait of a person against a light, uncluttered background). High resolution (~1440px wide or more). Light/bright imagery reads best because the inversion flips it to a dramatic dark negative inside the lens. Referenced by the hidden <img> in the container; the shader center-crops it to cover the viewport.

Behavior notes

  • Initial state: plain photo, no lens (u_radius starts at 0 and targetRadius is 0 until the first mousemove lands inside the container).
  • The lens closes (radius eases to 0) whenever the cursor exits the container rect and when the container is less than 10% visible (IntersectionObserver).
  • Mouse tracking is document-level plus a scroll listener re-check, so the effect stays correct in a scrolling page; the component itself does not hijack scroll.
  • Desktop / pointer-driven only — no touch handling, no click states, no reduced-motion branch. The rAF loop runs continuously.
  • No window-resize handling in the original; keep the renderer at its initial container size.