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.

Images

This component ships with 1 reference asset, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.

https://motionprompts.dev/c/turbulent-inversion-lens-hover-effect-javascript/portrait.jpeg

They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.

Using this outside its demo page

This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.

  • Palette on :root--ink, --gold, --black, --font-display, --font-mono. These names are not namespaced and they collide: --ink is defined by 164 of the 219 components in this catalogue, --paper by 94, --muted by 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix.
  • **Rules on *, body** — the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it.

Adapting this to React

Everything above describes a self-booting module: mount(config) walks every .inversion-lens container on the page, calls initHoverEffect on each, and folds their individual destroy functions into the single teardown it returns. The catalogue's own editor hook (window.MP.register) already leans on that mount/destroy shape being callable more than once with a different config object — which is useful, because it is also exactly the contract useEffect wants. What doesn't survive the trip is the branch that decides *whether* to call mount() at all, and the assumption, baked into document.querySelectorAll(".inversion-lens") and the document-level mousemove listener, that this script owns the whole page rather than one subtree of a React tree.

Under React 19 with StrictMode, every effect mounts, unmounts, and mounts again before anything reaches the screen. Here that lands on the async seam specifically: setupEventListeners() and animate() only run from inside the loader.load callback, not synchronously when initHoverEffect is called, so a StrictMode unmount can land while the texture is still in flight. If the callback doesn't check for that, it goes on to build a second scene against a container whose first <canvas> is still attached, bind a second mousemove listener next to the first (both racing to write their own lerpedMouse), start a second IntersectionObserver, and kick off a second requestAnimationFrame loop — four leaks from one missed check, all downstream of a single callback, and none of it will reproduce in a production build, because only development does the double mount. The script already guards this with its own destroyed boolean; the port has to keep that guard and tie it to the effect's lifetime instead of treating it as incidental.

*(1) The entry point* — The fallback branch (the else that runs when there's no window.MP) checks document.readyState before subscribing to DOMContentLoaded — dead weight in React, since useEffect already runs after commit. Drop that branch and the window.MP branch both; neither the readiness guard nor the editor-knob hook has a React equivalent to preserve. What's left is mount(Object.assign({}, DEFAULTS)), and that call, plus the teardown it returns, is the entire body of a useEffect with an empty dependency array. Because mount() already returns its own multi-container destroy, resist inlining initHoverEffect by hand — calling mount() once per effect and returning exactly what it hands back is less code than reimplementing its loop.

*(2) Element lookups* — document.querySelectorAll(".inversion-lens") exists to find containers this script doesn't otherwise know about; a React component knows its container by definition. One <InversionLens> instance should own a single root ref and pass that one element straight to initHoverEffect, instead of asking the whole document for every .inversion-lens and trusting that React only ever rendered one. Scope container.querySelector("img") the same way, under the ref. The mousemove listener stays bound to document by design — it has to notice the cursor re-entering the container from anywhere on the page — but the getBoundingClientRect test that decides "inside" already scopes it correctly, so the only change needed is making sure it reads the ref's current element rather than a container captured in a stale closure from a prior mount.

*(3) Cleanup — the async guard, the rAF loop, and the GPU resources* — Set the destroyed-style boolean (call it cancelled, to match this catalogue's convention) as the first line of the cleanup, because it is the only thing standing between a texture that resolves late and a scene built for a container that's already gone. Store the id animate()'s own requestAnimationFrame call returns and call cancelAnimationFrame on it in the same cleanup — that id doesn't exist until the texture has already loaded and animate() has run once, so the cleanup has to handle both "texture never arrived" (nothing to cancel) and "texture arrived, loop is running" (cancel it), which the flag distinguishes for you. Remove the mousemove listener from document and the scroll listener from window using the same function references setupEventListeners created, disconnect the IntersectionObserver, then dispose the loaded texture, the PlaneGeometry, and the ShaderMaterial, and finally tear down the renderer: renderer.dispose(), renderer.forceContextLoss(), and remove renderer.domElement from the DOM. Skipping forceContextLoss() doesn't fail on the very next remount — it fails several visits later, when the browser's shared WebGL context budget runs out and a *later* mount of this or any other three-family component on the page can no longer get a context at all.

*(4) Rendering this in @react-three/fiber* — The whole scene is one full-clip-space quad, so it maps onto R3F almost directly: <Canvas orthographic camera={{ left: -1, right: 1, top: 1, bottom: -1, near: 0, far: 1 }}> replaces the hand-built WebGLRenderer / Scene / OrthographicCamera trio, and a <mesh> wrapping <planeGeometry args={[2, 2]}> and a <shaderMaterial uniforms={…}> replaces the manual Mesh / ShaderMaterial construction. Carry the vertex shader's gl_Position = vec4(position, 1.0) over unchanged — bypassing the camera's projection entirely is what makes the quad fill the clip space regardless of the orthographic bounds fiber wires in, and "fixing" it into a normal projection multiply would break the fullscreen coverage. The animate() loop becomes one useFrame callback that advances lerpedMouse toward targetMouse, bumps the shader clock, and eases u_radius toward targetRadius, writing all three onto the material's uniforms ref — do not also keep a requestAnimationFrame loop running inside the <Canvas>, since useFrame already fires once per rendered frame and a second loop would drive the same uniforms at a competing rate. Replace new THREE.TextureLoader().load(img.src, …) with drei's useTexture(url), pointed at an image that resolves from your own domain — this also removes the need for the destroyed-flag guard around scene construction, since Suspense unmounts cleanly instead of racing a bare callback, though the document-level listeners and the IntersectionObserver still need the manual cleanup described above. There's no manual resize handler in the original to delete, but that isn't license to leave u_resolution fixed at its first-mount value either: <Canvas> resizes its drawing buffer on its own as the container's box changes, so read the live size from useThree((state) => state.size) inside useFrame and push it into u_resolution every frame instead of setting it once at construction.

A poster is mandatory here even though the asset is a single photograph rather than a heavy model: until useTexture resolves, the plane has nothing to sample and the container shows a bare frame. The hidden <img> already in the markup — invisible in the vanilla version only because it exists to hand its src to the texture loader — is that poster: keep it visible, sized to cover the container the way the shader's texCoord remap does, and let the canvas paint over it once the first frame with a resolved texture has rendered.

Skip drei's Environment, preset or otherwise. The fragment shader here is fully unlit: it samples u_texture, computes a luma-based inversion, and mixes it against the untouched frame through a noise-perturbed step mask — there is no normal, no reflection, nothing that would ever read an environment map. Reaching for Environment would add a dependency on the third-party CDN hard-coded into drei's presets for a scene that has no use for it, and it fails closed — an unlit scene — the moment that CDN is unreachable.