All components

Halide — Chromatic Aberration Lens Archive

JavaScript animation component · Published 2026-07-21 · by vanguardia.dev

Open live demo ↗ Raw prompt (.md)

What it does

Halide, an editorial photo studio, presents its image archive as a full-screen grid that pans with the cursor while a WebGL/GLSL lens magnifies the plates beneath the pointer and splits their RGB channels into a chromatic-aberration fringe. Fixed HUD chips (wordmark, volume, caption, hint) frame the effect; the grid is rasterized to a canvas texture and sampled by the fragment shader every frame, eased via requestAnimationFrame (no GSAP).

How it's built

Categorygallery
Techvanilla JS
Complexitypage
Performance costmedium
Mobile-safedesktop-first

webgl glsl shader chromatic-aberration lens-distortion cursor grid magnifier experimental

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

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:hidden window.
  • .container — the oversized panning grid (200vw × 200vh); the JS fills it with 300 image tiles and moves it around with transform: translate().
  • <canvas> — a single fixed, full-screen canvas pinned on top at z-index:1000 that renders the lens. It is pointer-events:none and alpha: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 by scale:

``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) with LINEAR min/mag filters and CLAMP_TO_EDGE wrap 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 requestAnimationFrame loops (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:1000 with pointer-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.