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.jsmodule exporting two GLSL strings:vertexShaderandfragmentShader(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)andlerpedMouse = 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.maskRadiuswhen hovering).isInView,isMouseInsideContainer,lastMouseX,lastMouseYtrackers.
Texture load → scene setup
Use new THREE.TextureLoader().load(img.src, callback). In the callback:
imageAspect = texture.image.width / texture.image.height— passed to the shader for cover-fit cropping.- Texture quality:
texture.minFilter = THREE.LinearMipMapLinearFilter; texture.magFilter = THREE.LinearFilter; texture.anisotropy = 16; scene = new THREE.Scene();camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1)(identity fullscreen camera — the vertex shader ignores matrices anyway).- 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 }; ``
- Mesh:
new THREE.Mesh(new THREE.PlaneGeometry(2, 2), new THREE.ShaderMaterial({ uniforms, vertexShader, fragmentShader })), added to the scene. - Renderer:
new THREE.WebGLRenderer({ antialias: true }),setPixelRatio(window.devicePixelRatio),setSize(container.clientWidth, container.clientHeight), then appendrenderer.domElementinto the container. (No resize handler — the canvas keeps its initial size.)
Pointer / visibility tracking
document.addEventListener("mousemove", ...)(on document, not the container) → callupdateCursorState(e.clientX, e.clientY).window.addEventListener("scroll", ...)→ re-runupdateCursorState(lastMouseX, lastMouseY)so the lens tracks correctly while the page scrolls under a stationary cursor.- An
IntersectionObserveron the container with{ threshold: 0.1 }: when the container leaves the viewport settargetRadius = 0.0(lens closes).
updateCursorState(x, y):
- Store
lastMouseX/Y, getcontainer.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) andtargetRadius = 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_timeadvances a fixed0.01per frame; the shader multiplies it byu_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 withmix(0.5 - 0.5/ratio, 0.5 + 0.5/ratio, uv.x)so a wider-than-viewport image is center-cropped horizontally (like CSSobject-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
screenAspectbeforedistance(), 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_radiusstarts at 0 andtargetRadiusis 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.