All components

Halation — Phantom Draggable Infinite Gallery

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

Open live demo ↗ Raw prompt (.md)

What it does

A Three.js orthographic full-screen plane renders an infinite grid of captioned images via a fragment shader, using image and text texture atlases and a barrel-distortion lens with a radial fade. Pointer/touch dragging pans the grid (offset lerped for inertia) and momentarily zooms in while dragging; a quick tap on a cell navigates to its project link.

How it's built

Categorygallery
Techthree
Complexitypage
Performance costheavy
Mobile-safeyes

three.js webgl glsl-shader drag infinite-grid texture-atlas lens-distortion editorial interactive

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

Phantom Draggable Infinite Gallery

Goal

Build a full-viewport, black WebGL infinite gallery: a single Three.js full-screen quad whose fragment shader procedurally tiles an endless grid of captioned image cells (image + title/year label per cell), viewed through a subtle barrel-distortion lens with a radial fade to black at the edges. Dragging with mouse or touch pans the grid in any direction forever (the offset is lerped every frame for inertial glide) and the view momentarily pulls back (zoom factor 1.0 → 1.25) while dragging, easing back to 1.0 on release. A quick tap (no movement, < 200 ms) resolves which cell was hit through the inverse lens math and navigates to that project's link. There is no DOM per cell — the entire grid, borders, images and text live in one shader.

Tech

Vanilla HTML/CSS/JS with ES module imports. Use three (npm) only — no GSAP, no plugins, no Lenis:

import * as THREE from "three";

All motion is a manual requestAnimationFrame loop with linear interpolation (lerpFactor = 0.075). Keep the data in a separate data.js (array of { title, image, year, href }) and the GLSL in shaders.js (exported vertexShader / fragmentShader template strings).

Layout / HTML

The page is nearly empty — everything renders into a canvas appended by JS:

<body>
  <section id="gallery">
    <div class="vignette-overlay"></div>
  </section>
  <script type="module" src="./script.js"></script>
</body>

JS appends the renderer.domElement canvas into #gallery.

Styling

  • Google Font IBM Plex Mono (weights 400, 500) — it is the page font AND the font drawn into the canvas text labels.
  • html, body { width:100%; height:100%; font-family:"IBM Plex Mono", monospace; background:#000; cursor:grab; user-select:none; overflow:hidden; }
  • body.dragging { cursor:grabbing; } (class toggled on drag start/end).
  • #gallery { position:relative; width:100vw; height:100svh; }
  • .vignette-overlay: position:absolute; inset:0 (top/left 0, width/height 100%); pointer-events:none; with

background: radial-gradient(ellipse at center, transparent 50%, rgba(0,0,0,0.1) 70%, rgba(0,0,0,0.75) 90%, rgba(0,0,0,1) 100%); — a CSS vignette layered over the canvas that, together with the shader's own radial fade, sinks the grid edges into black.

  • Global reset * { margin:0; padding:0; box-sizing:border-box; }.

Config (use these exact values)

const config = {
  cellSize: 0.75,                            // world units per grid cell
  zoomLevel: 1.25,                           // zoom factor while dragging (pull-back)
  lerpFactor: 0.075,                         // inertia for offset + zoom
  borderColor: "rgba(255, 255, 255, 0.15)",  // grid lines
  backgroundColor: "rgba(0, 0, 0, 1)",       // clear color / cell background
  textColor: "rgba(128, 128, 128, 1)",       // canvas label grey
  hoverColor: "rgba(255, 255, 255, 0)",      // hover tint (alpha 0 = effectively off)
};

A small helper parses these rgba() strings into [r,g,b,a] arrays with rgb normalized /255, to feed THREE.Vector4 uniforms and the clear color.

Scene setup

  • THREE.Scene, THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10) at z = 1.
  • THREE.WebGLRenderer({ antialias: true, alpha: false }), sized to #gallery's offsetWidth/offsetHeight, setPixelRatio(window.devicePixelRatio), clear color = background (black, alpha 1).
  • One THREE.PlaneGeometry(2, 2) mesh with a THREE.ShaderMaterial — a full-screen quad; the camera never moves.

Text label textures (canvas-drawn, one per project)

For each project draw a 2048×256 canvas: ctx.font = "80px IBM Plex Mono", fillStyle = the grey textColor, textBaseline = "middle", imageSmoothingEnabled = false. Draw title.toUpperCase() left-aligned at (30, 128) and the year right-aligned at (2048 − 30, 128). Wrap in a THREE.CanvasTexture with ClampToEdgeWrapping (both axes), NearestFilter (min+mag), flipY: false, generateMipmaps: false, format: THREE.RGBAFormat. (Tip for fidelity: make sure the font is loaded — e.g. document.fonts.load('80px "IBM Plex Mono"') — before drawing, or labels fall back to a default font.)

Texture atlases (image + text)

Load the 25 project images with THREE.TextureLoader (ClampToEdgeWrapping, LinearFilter; resolve a Promise when all have loaded). Then bake two atlases, each a square canvas of atlasSize × atlasSize tiles where atlasSize = Math.ceil(Math.sqrt(count)) (25 → 5×5) and every tile is 512×512:

  • Image atlas: canvas pre-filled black, each loaded image drawn stretched into its 512×512 tile (row-major: x = (i % atlasSize) * 512, y = floor(i / atlasSize) * 512).
  • Text atlas: canvas left transparent (clearRect), each 2048×256 label canvas drawn squashed into its 512×512 tile.

Both atlases become THREE.CanvasTexture with ClampToEdgeWrapping, LinearFilter, flipY: false.

Shader (the core effect — reproduce exactly)

Vertex shader

Pass-through: forward uv as vUv, standard projectionMatrix * modelViewMatrix * position.

Fragment shader uniforms
uOffset (vec2)  uResolution (vec2)  uBorderColor (vec4)  uHoverColor (vec4)
uBackgroundColor (vec4)  uMousePos (vec2, canvas px, -1,-1 = off)
uZoom (float, start 1.0)  uCellSize (float, 0.75)  uTextureCount (float, 25)
uImageAtlas (sampler2D)  uTextAtlas (sampler2D)
Fragment shader logic, step by step
  1. Screen space: screenUV = (vUv - 0.5) * 2.0 → [−1, 1].
  2. Barrel lens: radius = length(screenUV); distortion = 1.0 - 0.08 * radius * radius; distortedUV = screenUV * distortion; — coordinates contract toward the center as radius grows, bulging the grid at the periphery.
  3. World space: worldCoord = distortedUV * vec2(uResolution.x / uResolution.y, 1.0) (aspect correction), then worldCoord *= uZoom; worldCoord += uOffset;.
  4. Grid: cellPos = worldCoord / uCellSize; cellId = floor(cellPos); cellUV = fract(cellPos);.
  5. Hovered cell: run the mouse position through the *same* inverse transform (NDC → distortion → aspect → zoom → offset → cellId): convert uMousePos to NDC with (uMousePos / uResolution) * 2.0 - 1.0 and negate y. Compute cellDistance = length(cellCenter - mouseCellCenter) between cell centers and hoverIntensity = 1.0 - smoothstep(0.4, 0.7, cellDistance); if hovered and uMousePos.x >= 0.0, mix the cell background toward uHoverColor.rgb by hoverIntensity * uHoverColor.a. (With the config's alpha 0 this is visually disabled — keep the plumbing.)
  6. Grid lines: lineWidth = 0.005 in cellUV space; gridMask = smoothstep(0.0, lineWidth, cellUV.x) * smoothstep(0.0, lineWidth, 1.0 - cellUV.x) × same for y. Later: color = mix(color, uBorderColor.rgb, (1.0 - gridMask) * uBorderColor.a); → hairline white 15% grid.
  7. Image window: imageSize = 0.6, centered → imageBorder = 0.2; imageUV = (cellUV - 0.2) / 0.6. Soft edge: edgeSmooth = 0.01, alpha = product of smoothstep(-edgeSmooth, edgeSmooth, imageUV) and smoothstep(-edgeSmooth, edgeSmooth, 1.0 - imageUV) (x·y). Inside the window:
  8. texIndex = mod(cellId.x + cellId.y * 3.0, uTextureCount) — this **+ y*3 diagonal stride** is what scatters the 25 images across the infinite plane without obvious repetition.
  9. Atlas lookup: atlasSize = ceil(sqrt(uTextureCount)); atlasPos = vec2(mod(texIndex, atlasSize), floor(texIndex / atlasSize)); atlasUV = (atlasPos + imageUV) / atlasSize; and flip atlasUV.y = 1.0 - atlasUV.y for the image atlas only. Mix sampled rgb over the background by the soft-edge alpha.
  10. Caption band: occupies cellUV.x ∈ [0.05, 0.95], cellUV.y ∈ [0.88, 0.96] (textY = 0.88, textHeight = 0.08) — a thin strip below the image. Remap to textCoord (x normalized over 0.9, y over 0.08, then textCoord.y = 1.0 - textCoord.y), same atlas-position math (no extra y flip), sample the text atlas and composite by its alpha over the cell background.
  11. Radial fade: fade = 1.0 - smoothstep(1.2, 1.8, radius); gl_FragColor = vec4(color * fade, 1.0); — the shader itself darkens toward the screen corners underneath the CSS vignette.

Interaction + animation loop (exact values)

State: offset and targetOffset {x, y} (start 0,0), zoomLevel/targetZoom (start 1.0), mousePosition (start −1,−1), flags isDragging, isClick, clickStartTime, previousMouse.

  • Drag start (mousedown on document / touchstart with preventDefault): set isDragging = true, isClick = true, clickStartTime = Date.now(), add .dragging to body, record pointer. Then setTimeout(150ms) → if *still* dragging, set targetZoom = 1.25 (press-and-hold also triggers the pull-back).
  • Drag move (mousemove / touchmove with preventDefault): if dragging, compute deltaX/deltaY from the previous pointer. If |deltaX| > 2 || |deltaY| > 2: mark isClick = false and, if targetZoom is still 1.0, set it to 1.25 immediately. Pan: targetOffset.x -= deltaX * 0.003; targetOffset.y += deltaY * 0.003; (y inverted — world y is up). Store the pointer.
  • Release (mouseup, mouseleave on document, touchend): isDragging = false, remove .dragging, targetZoom = 1.0. Tap-to-navigate: if isClick and elapsed < 200 ms, invert the full lens math in JS to find the cell under the pointer: NDC screenX/screenY (y negated), distortion = 1.0 - 0.08 * r², worldX = screenX * distortion * (rect.width / rect.height) * zoomLevel + offset.x (same for y without aspect), cellX/cellY = floor(world / cellSize), texIndex = floor((cellX + cellY * 3.0) % count) with negative wrap (if < 0, add count), then window.location.href = projects[index].href.
  • Hover uniform: mousemove on the canvas writes the pointer position (canvas-relative px) into uMousePos; mouseleave resets it to (-1, -1).
  • Resize: update renderer size, pixel ratio and uResolution from #gallery's dimensions.
  • contextmenu is prevented; touch listeners registered with { passive: false }.

rAF loop (every frame):

offset.x += (targetOffset.x - offset.x) * 0.075;
offset.y += (targetOffset.y - offset.y) * 0.075;
zoomLevel += (targetZoom - zoomLevel) * 0.075;
// write uOffset + uZoom uniforms, then renderer.render(scene, camera)

The 0.075 lerp gives the drag a smooth, weighty glide — the grid keeps drifting briefly after release, and the zoom pull-back/return breathes in and out rather than snapping.

Data

25 projects, each { title, image, year, href: "/sample-project" }. Use these titles/years (they are painted on the cells): Motion Study 2024, Idle Form 2023, Blur Signal 2024, Still Drift 2023, Tidewalk 2024, Core Motion 2022, White Bloom 2024, Backrun 2023, Rushline 2024, Afterimage 2023, Shadowhead 2022, Opal Lace 2024, Glassprint 2024, Redshift 2023, White Noise 2023, Twin Field 2024, Petalloop 2023, Ghostwalk 2024, Heatwave 2023, Sky Drift 2024, Spindle 2022, Pacer 2023, Stride 2024, Cryo Pulse 2022, Velvet Blur 2024.

Assets / images

25 gallery images, one per project. Each is displayed stretched into a square 512×512 atlas tile and shown in the square image window of a cell, so square or near-square sources look best (any aspect will be squashed to 1:1). They read as one cohesive moody editorial / experimental motion-photography set on black: motion-blurred runners and striding figures, dramatic low-key portraits (hats, sunglasses, windswept hair), ghostly high-key figures and busts, abstract motion-blur vortices and spinning-blade radial blurs, a blurred flower, a surfer in a hazy seascape — muted palettes (blacks, whites, greys, pale blues) punctuated by a few vivid orange-red and green backdrops. Interchangeable in role; sequence them img1…img25 to match the title order above.

Behavior notes

  • The gallery is infinite in every direction — the grid is procedural, so panning never runs out; the 25 textures repeat on the x + 3y stride.
  • Works with mouse and touch (touch events preventDefault; page never scrolls — overflow: hidden).
  • The hover tint is wired but transparent (hoverColor alpha 0); changing that alpha lights up the hovered cell's background.
  • Heavy on GPU (full-screen fragment shader at device pixel ratio) but mobile-safe; no reduced-motion handling in the original — all motion is user-driven.
  • Nothing animates on load: the grid renders at offset (0,0), zoom 1.0, and waits for input.