Chromatic Aberration Lens Gallery
Goal
Build a full-screen, edge-to-edge grid of editorial portraits that pans slowly as the cursor moves, overlaid by a circular magnifying lens that follows the pointer. Inside the lens the imagery is zoomed in and its RGB channels are split apart into a chromatic-aberration fringe — red pushes one way, blue the other — with the distortion ramping up toward the lens rim like real glass. Outside the lens you just see the flat grid. The whole thing is cursor-driven and runs forever: nothing scrolls, nothing clicks, it simply reacts to mousemove. The star effect is the WebGL/GLSL lens distortion + chromatic aberration sampled live from the grid every frame.
Tech
Vanilla HTML/CSS/JS. No GSAP, no libraries, no framework, no npm dependencies — the entire effect is hand-rolled with the raw WebGL API + a GLSL fragment shader, plus two requestAnimationFrame lerp loops for easing. A single ES-module script (<script type="module" src="./script.js">) drives everything. Do not reach for Three.js, a shader helper, or a tween library; the original talks to gl directly with a hand-written vertex + fragment shader pair, and eases the pointer/pan with manual linear-interpolation loops. Must run in a fresh Vite + npm project (though no packages are actually imported).
Layout / HTML
Almost nothing lives in the HTML — the grid is generated by JS and the lens is a full-screen <canvas> overlay.
<body>
<div class="viewport">
<div class="container"></div>
</div>
<canvas></canvas>
<script type="module" src="./script.js"></script>
</body>
.viewport— a fixed, full-screen,overflow:hiddenwindow..container— the oversized panning grid (200vw × 200vh); the JS fills it with 300 image tiles and moves it around withtransform: translate().<canvas>— a single fixed, full-screen canvas pinned on top atz-index:1000that renders the lens. It ispointer-events:noneandalpha:true, so everywhere outside the lens circle it is fully transparent and you see the real DOM grid underneath; only the circular lens region is painted.
Styling
Reset
* { margin:0; padding:0; box-sizing:border-box; }
Viewport
.viewport { position:fixed; top:0; left:0; width:100vw; height:100vh; overflow:hidden; } — clips the oversized grid to the screen.
Grid container (the panning surface)
.container {
position:absolute;
width:200vw; /* twice the viewport each way — there is room to pan */
height:200vh;
display:grid;
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap:4px;
padding:4px;
transform: translate(0, 0);
will-change: transform;
}
An auto-filled grid of ~100px columns with a tight 4px gap and 4px padding, so tiles pack densely edge to edge. transform is the only animated property (written by the pan loop).
Tiles & images
.img-wrapper { position:relative; width:100%; padding-bottom:100%; overflow:hidden; } /* forces a square cell */
img { position:absolute; top:0; left:0; width:100%; height:100%; object-fit:cover; }
Every cell is a 1:1 square (padding-bottom trick); the photo fills it with object-fit:cover (center-crop). No borders, no radius, no captions — a raw contact-sheet look.
Canvas overlay
canvas {
position:fixed; top:0; left:0;
width:100%; height:100%;
pointer-events:none;
z-index:1000;
}
There is no page background color, no typography, no UI chrome — the grid *is* the page.
The effect (be exact — this is the whole component)
There is no timeline library and no GSAP. The component is: (1) a JS-built grid, (2) a pointer-driven pan eased with a rAF lerp, (3) a per-frame rasterization of the DOM grid into a WebGL texture, and (4) a GLSL fragment shader that draws a magnifying, chromatically-aberrated lens at the (eased) cursor position. Reproduce the constants, formulas and the shader verbatim.
1. Build the grid
const imgSources = Array.from({ length: 25 }, (_, i) => `/…/img${i + 1}.jpg`); // 25 source photos
function getRandomImage() { return imgSources[Math.floor(Math.random() * imgSources.length)]; }
Create 300 .img-wrapper divs, each holding one <img> whose src is a random pick from the 25 sources (so the same photo repeats several times, scattered). Append them all into .container.
2. Pointer + pan state
Module-scope state (all numbers):
let mouseX = innerWidth/2, mouseY = innerHeight/2; // eased lens position (px)
let targetMouseX = mouseX, targetMouseY = mouseY; // raw pointer target
let targetX = 0, targetY = 0; // pan target (px)
let currentX = 0, currentY = 0; // eased pan (px)
On mousemove(e): set targetMouseX = e.clientX, targetMouseY = e.clientY, then call updatePan(e.clientX, e.clientY).
updatePan(mx, my) — compute how far the oversized grid may slide and map the pointer into that range:
const maxX = container.offsetWidth - innerWidth; // extra width beyond the viewport
const maxY = container.offsetHeight - innerHeight; // extra height beyond the viewport
targetX = -((mx / innerWidth) * maxX * 0.75); // note the 0.75 damping
targetY = -((my / innerHeight) * maxY * 0.75);
So moving the cursor to the right edge pushes the grid left (negative translate), revealing its right side, but only up to 75% of the available travel.
Pan loop animatePan() (own rAF, infinite):
const ease = 0.035; // very slow, heavy drift
currentX += (targetX - currentX) * ease;
currentY += (targetY - currentY) * ease;
container.style.transform = `translate(${currentX}px, ${currentY}px)`;
requestAnimationFrame(animatePan);
A low 0.035 lerp factor makes the grid glide toward the target lazily — the pan always trails the cursor by a wide margin.
3. WebGL setup
const gl = canvas.getContext("webgl", { preserveDrawingBuffer:false, antialias:true, alpha:true });
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); // standard alpha over → transparent outside the lens
Compile the two shaders below, link a program, useProgram. Draw a full-screen quad as a TRIANGLE_STRIP of 4 vertices:
const vertices = new Float32Array([ -1,-1, 1,-1, -1,1, 1,1 ]);
Bind them to attribute aPosition (2 floats). Create one texture on TEXTURE0, bound to sampler uniform iChannel0 (= 0).
Vertex shader — passthrough that also derives 0→1 UVs:
attribute vec4 aPosition;
varying vec2 fragCoord;
void main() {
gl_Position = aPosition;
fragCoord = (aPosition.xy * 0.5 + 0.5); // clip space (−1..1) → UV (0..1)
}
4. Rasterize the DOM grid into the texture — every frame (updateTexture)
The shader samples the grid as an image, so each frame the live DOM is drawn into an offscreen 2D canvas and uploaded:
- Create a temp
<canvas>at 4× supersample:const scale = 4; tempCanvas.width = floor(innerWidth*scale); height = floor(innerHeight*scale);. tempCtx.imageSmoothingEnabled = true; imageSmoothingQuality = "high";then fill white (fillStyle="white"; fillRect(...)) — white is the backdrop behind any gaps.- Read the container's current transform and replay it on the temp context so the rasterized grid matches what's on screen, at 4×:
``js const viewportRect = container.getBoundingClientRect(); const matrix = new DOMMatrix(getComputedStyle(container).transform); tempCtx.setTransform(matrix.a, matrix.b, matrix.c, matrix.d, matrix.e * scale, matrix.f * scale); ``
- For every
<img>in the container, draw it into the temp canvas at its wrapper's position (relative to the container's top-left), all multiplied byscale:
``js const parent = img.parentElement.getBoundingClientRect(); tempCtx.drawImage(img, (parent.left - viewportRect.left) * scale, (parent.top - viewportRect.top) * scale, parent.width * scale, parent.height * scale); ``
- Reset with
tempCtx.setTransform(1,0,0,1,0,0), then upload:gl.texImage2D(TEXTURE_2D, 0, RGBA, RGBA, UNSIGNED_BYTE, tempCanvas)withLINEARmin/mag filters andCLAMP_TO_EDGEwrap on both axes.
(This means the whole visible grid is re-rendered to a 4×-resolution bitmap and re-uploaded to the GPU on every animation frame — the source of the "medium" perf cost.)
5. Render loop (render, own infinite rAF)
const ease = 0.1; // lens follows pointer at 0.1 lerp
mouseX += (targetMouseX - mouseX) * ease;
mouseY += (targetMouseY - mouseY) * ease;
canvas.width = innerWidth; canvas.height = innerHeight;
gl.viewport(0, 0, canvas.width, canvas.height);
updateTexture();
gl.uniform2f(iResolutionLoc, canvas.width, canvas.height);
gl.uniform2f(iMouseLoc, mouseX, canvas.height - mouseY); // NOTE: Y is flipped for GL space
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
requestAnimationFrame(render);
Two independent easings: the lens chases the pointer at 0.1 (fairly snappy) while the pan drifts at 0.035 (sluggish), so the lens and the imagery move at different rates.
6. The fragment shader (the lens — reproduce exactly)
Uniforms: vec2 iResolution, vec2 iMouse, sampler2D iChannel0; varying vec2 fragCoord (0..1 UV).
Fixed parameters inside main:
vec2 sphereCenter = iMouse.xy;
float sphereRadius = iResolution.y * 0.3; // lens radius = 30% of viewport height
float focusFactor = 0.25; // inner "flat" focus zone = 25% of radius
float chromaticAberrationFactor = 0.25; // strength of the R/B split
float zoom = 1.75; // magnification inside the lens
Step A — zoom the sample UV around the lens center:
vec2 zoomUV(vec2 uv, vec2 center, float zoom) {
float z = 1.0 / zoom;
return (uv - center) * z + center; // pull UVs toward center → magnify
}
vec2 spehereCenterUv = sphereCenter / iResolution;
vec2 zoomedUv = zoomUV(fragCoord, spehereCenterUv, 1.75);
Step B — the lens distortion + chromatic aberration (getLensDistortion, called with p = fragCoord*iResolution, uv = zoomedUv):
vec2 distortionDirection = normalize(p - sphereCenter);
float focusRadius = sphereRadius * focusFactor; // = radius*0.25
float focusStrength = sphereRadius / 5000.0; // base displacement magnitude
float focusSdf = length(sphereCenter - p) - focusRadius; // signed dist from focus edge
float speherSdf = length(sphereCenter - p) - sphereRadius; // signed dist from lens edge
float inside = smoothstep(0.0, 1.0, -speherSdf / (sphereRadius * 0.001)); // ~hard 0/1 mask of the disc
float magnifierFactor = focusSdf / (sphereRadius - focusRadius);
float mFactor = clamp(magnifierFactor * inside, 0.0, 1.0);
mFactor = pow(mFactor, 5.0); // distortion stays ~0 in the focus zone, ramps hard near the rim
vec3 distortionFactors = vec3(
mFactor * focusStrength * (1.0 + chromaticAberrationFactor), // R displaced more
mFactor * focusStrength, // G reference
mFactor * focusStrength * (1.0 - chromaticAberrationFactor) // B displaced less
);
Each channel is displaced along distortionDirection by its own factor via:
vec2 getDistortedUv(vec2 uv, vec2 direction, float factor) {
vec2 d = direction; d.y *= 2.0; // vertical displacement doubled
return uv - d * factor;
}
vec2 uv_R = getDistortedUv(uv, distortionDirection, distortionFactors.r);
vec2 uv_G = getDistortedUv(uv, distortionDirection, distortionFactors.g);
vec2 uv_B = getDistortedUv(uv, distortionDirection, distortionFactors.b);
Then each distorted UV is vertically flipped about the lens center (the DOM texture is Y-down vs GL Y-up):
vec2 fixRotation(vec2 uv, vec2 center) {
vec2 c = uv - center; c.y = -c.y; return c + center;
}
vec2 sphereCenterUv = sphereCenter / iResolution;
uv_R = fixRotation(uv_R, sphereCenterUv); // same for uv_G, uv_B
Step C — compose:
vec4 baseTexture = texture2D(iChannel0, fragCoord); // flat grid at this pixel
vec3 imageDistorted = vec3(
texture2D(iChannel0, uv_R).r, // sample RED from the R-shifted UV
texture2D(iChannel0, uv_G).g, // sample GREEN from the G UV
texture2D(iChannel0, uv_B).b // sample BLUE from the B-shifted UV
);
vec3 result = mix(baseTexture.rgb, imageDistorted, inside); // only inside the disc
gl_FragColor = vec4(result, inside); // alpha = disc mask → transparent elsewhere
Net feel: a crisp circular lens (radius = 30% of viewport height) glides under the cursor. Its inner ~25% is a clean 1.75× magnification; from there to the rim the RGB channels progressively peel apart (red pulling outward, blue inward, vertical split exaggerated ×2) so the fringe intensifies toward the edge — like looking through a thick glass sphere. Outside the disc the canvas is transparent and the plain grid shows through, panning lazily against the snappier lens.
7. Init order & resize
init(): build the grid → await the first image's load (so the texture isn't blank) → initWebGL() → attach listeners → animatePan() → render().
On resize: reset canvas.width/height and gl.viewport to the new size; recenter everything — targetMouseX = mouseX = innerWidth/2, targetMouseY = mouseY = innerHeight/2, and targetX = currentX = targetY = currentY = 0 (snap the pan back to origin).
Assets / images
25 source photos, a cohesive high-fashion editorial portrait set — studio and location fashion photography, one subject per frame, head-and-shoulders to full-body. Keep the collection tonally varied but of a piece: a mix of full-color and high-contrast black-and-white frames, moody low-key lighting alongside clean high-key studio seamless, and a restrained palette (neutral tan / grey / concrete backdrops, creamy off-whites, denim and leather, with occasional saturated grounds — blue, red, warm tan — as accents). Subjects wear editorial styling: leather jackets, knit balaclavas and fur hats, denim, tailored blazers, avant-garde beauty/painted-face looks, sunglasses; a range of hair colors and skin tones, several close-ups with visible freckles. Source aspect ratios can be anything — every tile is center-cropped to a 1:1 square (~100px cells, object-fit:cover), and the shader zooms 1.75× so composition, contrast and tone matter far more than resolution. Any set of ~25 minimal, editorial fashion portraits at square-croppable framing works; frames with clear tonal contrast make the chromatic fringe read best. Do not use real brand imagery or client logos. The same 25 are scattered randomly across 300 tiles, so repeats are expected.
Behavior notes
- Desktop / pointer only. The whole effect is
mousemove-driven — there is no touch, scroll, click or keyboard interaction, and no reduced-motion branch. It is not mobile-safe. - Runs forever. Two infinite
requestAnimationFrameloops (pan at 0.035 lerp, lens+texture at 0.1 lerp) run continuously; the grid re-rasterizes to a 4× offscreen canvas and re-uploads to the GPU every frame (medium perf cost). - Transparent overlay. The lens canvas sits at
z-index:1000withpointer-events:none; outside the circular disc its alpha is 0, so the real DOM grid — not a shader copy — is what you see there. - First-paint guard. Rendering waits for the first grid image to load so the initial texture upload isn't empty/white.