Lens Grid
An endless, draggable wall of images rendered in WebGL and seen through a spherical lens — whatever drifts under the middle of the screen bulges toward you while the rest curves away.
A free, copy-paste media component for React, Next.js and shadcn/ui — built with Tailwind CSS.
Configuration
Tweak these live from theConfigurecontrol in the preview.
Interaction Type
- Drag in any direction — the grid never ends
- Scroll or flick and it glides to a stop
- A glass lens magnifies whatever sits in the centre
- Click a tile to open it full size
Dependency
No dependencies — plain React. Just drop the file in.
One-time setup for the CLI
Register the namespace once in your components.json and every component below installs by name:
"registries": {
"@harshdev-ui": "https://ui.harshpandav.dev/r/{name}.json"
}Prefer not to? Skip the setup and pass the URL straight to the CLI, or just copy the file from Source code below — it is a single file with no build step.
[ shadcn CLI 3.0 registry ]…or add the component in one command
Install via CLI
pnpm dlx shadcn add @harshdev-ui/lens-gridHow to use
Use Lens Grid when you want a wall of imagery that invites roaming rather than scrolling — a photography index, a case-study wall, a team page, an archive. Pass an array of image URLs and it tiles them across an infinite plane you can drag in any direction; the grid repeats forever, so a couple of dozen images is enough to fill a canvas that never ends.
The lens is the point. Everything near the middle of the viewport is pushed outward by a hemisphere falloff, so the centre reads large and close while the edges curve away like a glass marble. Set lensMode to "pinch" to invert it, or "flat" to switch the distortion off and keep the infinite grid on its own. Strength and radius are separate controls: strength decides how hard it bends, radius decides how much of the screen the lens covers.
Give it a fixed height — the canvas fills its container, and the parent decides how much of the page it owns.
Basic usage
import { LensGrid } from "@/components/harsh-ui/lens-grid";
const Demo = () => (
<div className="h-screen w-full">
<LensGrid images={["/a.jpg", "/b.jpg", "/c.jpg"]} />
</div>
);Tuning the lens
Strength bends, radius covers. A small radius with high strength gives a tight magnifier; a large radius with low strength gives a gentle whole-screen curve.
<LensGrid
images={images}
lensMode="bulge"
lensStrength={0.8}
lensRadius={0.7}
/>Colour only inside the lens
Everything outside the focus drains to greyscale, so the eye goes exactly where the lens is.
<LensGrid
images={images}
desaturate
colorRadius={0.5}
/>Shapes
Every silhouette is a distance field, so tileRadius softens the corners of a hexagon or a triangle exactly the way it rounds a square.
<LensGrid images={images} tileShape="circle" />
<LensGrid images={images} tileShape="hexagon" tileRadius={8} gap={12} />
<LensGrid images={images} tileShape="diamond" tileRotation={6} />Click to preview
The component reports which image was clicked and where; the preview itself is yours to build, so it stays dependency-free.
const [shot, setShot] = useState(null);
<LensGrid images={images} onImageClick={setShot} />
{shot && (
<Lightbox src={shot.src} onClose={() => setShot(null)} />
)}Ambient drift
With no pointer the wall keeps moving on its own — good for hero sections and previews. Honours prefers-reduced-motion.
<LensGrid
images={images}
autoDrift
driftSpeed={0.3}
driftAngle={35}
/>Loading state
const [progress, setProgress] = useState(0);
<LensGrid images={images} onProgress={setProgress} />
{progress < 1 && <Spinner value={progress} />}Demo
import { LensGridDemo } from "@/components/demos/lens-grid-demo";
const Demo = () => (
<div className="h-screen w-full">
<LensGridDemo />
</div>
);Props
Notes
- Zero dependencies — raw WebGL 1. No three.js, no shader library, no animation library. Roughly 400 lines including both shaders.
- The lens is a vertex effect, so each tile is a 20×20 subdivided quad rather than two triangles — a flat quad would only move its four corners and the image would shear instead of curve.
- Shapes are signed distance fields, not alpha masks. That is what lets one tileRadius control round every silhouette: subtracting r from a distance field rounds whatever it describes, so a hexagon and a triangle soften with the same code the square uses. The feather width is derived from the tile's pixel size, so edges stay one pixel soft whether tiles are 100px or 280px.
- Clicks are hit-tested by pushing the point back through the lens. The forward map scales a radius by a hemisphere profile that has no closed-form inverse, so it bisects for the pre-lens radius — twenty-four iterations, only on click, never in the frame loop. A press that travels under 8px in under half a second counts as a click; anything else is a drag.
- The wheel matches the drag by default: pushing the wheel moves the wall the way dragging in that direction would. invertScroll flips it to page-scroll semantics for people who expect that instead.
- The hemisphere profile is sqrt(max(1 - t², 0)) and it is multiplied by a smoothstep that fades the lens out before its own rim. The max() keeps the square root real, and the fade means distortion blends into flat space instead of leaving a seam where a hard if(dist < radius) cut would.
- Dragging moves a target, and the view chases it with a damped lerp each frame. Driving the view straight from the pointer is what makes canvas dragging feel twitchy; the gap between target and view is where the weight comes from.
- Culling grows with lens strength, because the bulge pulls off-screen tiles into view — but only by as much as the distortion can actually reach, which keeps a full-screen canvas at roughly 60 draw calls rather than several hundred.
- Attribute and uniform locations are resolved once at setup, and the vertex layout, blend mode and sampler unit are bound once rather than restated every frame. getUniformLocation inside the frame loop is a synchronous driver round-trip and it adds up fast when it runs per tile.
- A settled grid draws nothing. The loop tracks whether anything actually moved — drag, momentum, drift, a texture landing, a config edit — and returns before clearing if the next frame would be identical to the last.
- An IntersectionObserver stops all GPU work while the canvas is off-screen, and document.hidden does the same for a backgrounded tab. This matters most when the component sits in a card below the fold.
- Positions and texture coordinates share one interleaved buffer, and textures are power-of-two so WebGL 1 will mipmap them — without mipmaps the wall shimmers as tiles shrink.
- The backing store is capped at 2× devicePixelRatio — past that the extra fragments cost more than the sharpness is worth on a canvas this size.
- Config changes are read from a ref inside the loop, so moving a slider retunes the running animation instead of rebuilding the GL context and re-uploading every texture.
- Teardown cancels the frame and deletes every texture, buffer and program, but deliberately does not call WEBGL_lose_context: React remounts reuse the same <canvas>, and a force-lost context is never handed back, so the next mount would get a dead context and fail to compile its shaders.
- webglcontextlost is handled and preventDefault'd so the browser will offer a restore, and webglcontextrestored rebuilds every GPU resource from scratch.
- Recreated from a CodePen by ol-ivier (https://codepen.io/ol-ivier). The idea and the look are theirs; the React port, the damped input model, the culling, the rounded and tilted tiles and the lens-only colour are this version's.
Source code
The complete lens-grid.tsx — toggle TSX / JSX to copy it in either language.
"use client";
import { useEffect, useRef, useState } from "react";
/**
* Harsh Dev UI · Developed by Harsh Pandav
* https://harshpandav.dev · hello@harshpandav.dev
*
* An endless, draggable wall of images rendered in raw WebGL 1 and seen through
* a spherical lens that bulges (or pinches) whatever sits under the middle of
* the viewport. Zero dependencies — no three.js, no animation library.
*
* Tile silhouettes are signed distance fields, which is why one `tileRadius`
* rounds every shape: subtracting r from a distance field rounds whatever it
* describes. The hexagon and triangle primitives take an apothem rather than a
* circumradius, so they are scaled by cos(30°) to land on the tile edge instead
* of past it, where the quad has no fragments and the points come out sliced.
*/
export type LensMode = "bulge" | "pinch" | "flat";
export type TileShape =
| "square"
| "circle"
| "hexagon"
| "diamond"
| "triangle";
export type LensGridHit = {
index: number;
src: string;
clientX: number;
clientY: number;
};
export type LensGridProps = {
images: string[];
lensStrength?: number;
lensRadius?: number;
lensMode?: LensMode;
tileSize?: number;
gap?: number;
tileShape?: TileShape;
tileRadius?: number;
tileRotation?: number;
inertia?: number;
dragSpeed?: number;
invertScroll?: boolean;
autoDrift?: boolean;
driftSpeed?: number;
driftAngle?: number;
desaturate?: boolean;
colorRadius?: number;
background?: string;
className?: string;
onProgress?: (progress: number) => void;
onImageClick?: (hit: LensGridHit) => void;
};
const SUBDIVISIONS = 20;
const SHAPE_ID: Record<TileShape, number> = {
square: 0,
circle: 1,
hexagon: 2,
diamond: 3,
triangle: 4,
};
const VERTEX_SHADER = `
attribute vec2 aPosition;
attribute vec2 aTexCoord;
uniform vec2 uResolution;
uniform vec2 uOffset;
uniform vec2 uTilePos;
uniform vec2 uTileSize;
uniform float uRotation;
uniform float uLensStrength;
uniform float uLensRadius;
varying vec2 vTexCoord;
varying float vLensT;
void main() {
vec2 pos = aPosition * uTileSize + uTilePos - uOffset;
vec2 mid = uTilePos + uTileSize * 0.5 - uOffset;
pos -= mid;
float c = cos(uRotation);
float s = sin(uRotation);
pos = vec2(pos.x * c - pos.y * s, pos.x * s + pos.y * c);
pos += mid;
vec2 uv = pos / uResolution;
vec2 delta = uv - vec2(0.5);
float aspect = uResolution.x / uResolution.y;
delta.x *= aspect;
float t = clamp(length(delta) / uLensRadius, 0.0, 1.0);
float dome = sqrt(max(1.0 - t * t, 0.0));
float edge = smoothstep(1.0, 0.7, t);
delta *= 1.0 + uLensStrength * dome * edge;
delta.x /= aspect;
pos = (vec2(0.5) + delta) * uResolution;
gl_Position = vec4(pos / uResolution * 2.0 - 1.0, 0.0, 1.0);
vTexCoord = aTexCoord;
vLensT = t;
}
`;
const FRAGMENT_SHADER = `
precision mediump float;
varying vec2 vTexCoord;
varying float vLensT;
uniform sampler2D uSampler;
uniform float uTileShape;
uniform float uTileRadius;
uniform float uAA;
uniform float uDesaturate;
uniform float uColorRadius;
float ndot(vec2 a, vec2 b) { return a.x * b.x - a.y * b.y; }
float sdBox(vec2 p, vec2 b) {
vec2 q = abs(p) - b;
return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0);
}
float sdHexagon(vec2 p, float r) {
const vec3 k = vec3(-0.8660254, 0.5, 0.5773503);
p = abs(p);
p -= 2.0 * min(dot(k.xy, p), 0.0) * k.xy;
p -= vec2(clamp(p.x, -k.z * r, k.z * r), r);
return length(p) * sign(p.y);
}
float sdRhombus(vec2 p, vec2 b) {
p = abs(p);
float h = clamp(ndot(b - 2.0 * p, b) / dot(b, b), -1.0, 1.0);
float d = length(p - 0.5 * b * vec2(1.0 - h, 1.0 + h));
return d * sign(p.x * b.y + p.y * b.x - b.x * b.y);
}
float sdTriangle(vec2 p, float r) {
const float k = 1.7320508;
p.x = abs(p.x) - r;
p.y = p.y + r / k;
if (p.x + k * p.y > 0.0) p = vec2(p.x - k * p.y, -k * p.x - p.y) / 2.0;
p.x -= clamp(p.x, -2.0 * r, 0.0);
return -length(p) * sign(p.y);
}
float shapeDistance(vec2 p, float r) {
float inner = 0.5 - r;
if (uTileShape < 0.5) return sdBox(p, vec2(inner)) - r;
if (uTileShape < 1.5) return length(p) - 0.5;
if (uTileShape < 2.5) return sdHexagon(p, inner * 0.8660254) - r;
if (uTileShape < 3.5) return sdRhombus(p, vec2(inner)) - r;
return sdTriangle(vec2(p.x, p.y + inner * 0.2886751), inner) - r;
}
void main() {
vec2 uv = vec2(vTexCoord.x, 1.0 - vTexCoord.y);
vec4 color = texture2D(uSampler, uv);
if (uTileShape > 0.5 || uTileRadius > 0.001) {
float d = shapeDistance(vTexCoord - 0.5, uTileRadius);
color.a *= 1.0 - smoothstep(-uAA, uAA, d);
}
if (uDesaturate > 0.5) {
float grey = dot(color.rgb, vec3(0.299, 0.587, 0.114));
color.rgb = mix(color.rgb, vec3(grey), smoothstep(uColorRadius * 0.4, uColorRadius, vLensT));
}
if (color.a < 0.01) discard;
gl_FragColor = vec4(color.rgb * color.a, color.a);
}
`;
function compile(gl: WebGLRenderingContext, type: number, src: string) {
const shader = gl.createShader(type);
if (!shader) return null;
gl.shaderSource(shader, src);
gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
console.error(
"[LensGrid]",
gl.isContextLost()
? "context lost while compiling"
: `shader: ${gl.getShaderInfoLog(shader)}`,
);
gl.deleteShader(shader);
return null;
}
return shader;
}
function buildProgram(gl: WebGLRenderingContext) {
const vs = compile(gl, gl.VERTEX_SHADER, VERTEX_SHADER);
const fs = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER);
if (!vs || !fs) return null;
const program = gl.createProgram();
if (!program) return null;
gl.attachShader(program, vs);
gl.attachShader(program, fs);
gl.linkProgram(program);
gl.deleteShader(vs);
gl.deleteShader(fs);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error("[LensGrid] link:", gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return null;
}
return program;
}
function buildQuad(subdivisions: number) {
const side = subdivisions + 1;
const data = new Float32Array(side * side * 4);
const indices = new Uint16Array(subdivisions * subdivisions * 6);
let v = 0;
for (let y = 0; y <= subdivisions; y++) {
for (let x = 0; x <= subdivisions; x++) {
const u = x / subdivisions;
const w = y / subdivisions;
data[v++] = u;
data[v++] = w;
data[v++] = u;
data[v++] = w;
}
}
let i = 0;
for (let y = 0; y < subdivisions; y++) {
for (let x = 0; x < subdivisions; x++) {
const a = y * side + x;
indices[i++] = a;
indices[i++] = a + 1;
indices[i++] = a + side;
indices[i++] = a + 1;
indices[i++] = a + side + 1;
indices[i++] = a + side;
}
}
return { data, indices };
}
function tileImageIndex(x: number, y: number, count: number) {
let h = (x * 374761393 + y * 668265263) | 0;
h = Math.imul(h ^ (h >>> 13), 1274126177);
return ((h ^ (h >>> 16)) >>> 0) % count;
}
function lensScale(r: number, radius: number, strength: number) {
const t = Math.min(r / radius, 1);
const dome = Math.sqrt(Math.max(1 - t * t, 0));
const e = Math.min(Math.max((1 - t) / 0.3, 0), 1);
return 1 + strength * dome * (e * e * (3 - 2 * e));
}
const isPowerOfTwo = (n: number) => n > 0 && (n & (n - 1)) === 0;
export function LensGrid({
images,
lensStrength = 0.45,
lensRadius = 1.1,
lensMode = "bulge",
tileSize = 180,
gap = 6,
tileShape = "square",
tileRadius = 6,
tileRotation = 0,
inertia = 0.95,
dragSpeed = 1,
invertScroll = false,
autoDrift = true,
driftSpeed = 0.25,
driftAngle = 35,
desaturate = false,
colorRadius = 0.55,
background = "var(--background)",
className,
onProgress,
onImageClick,
}: LensGridProps) {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [epoch, setEpoch] = useState(0);
const opts = useRef({
lensStrength,
lensRadius,
lensMode,
tileSize,
gap,
tileShape,
tileRadius,
tileRotation,
inertia,
dragSpeed,
invertScroll,
autoDrift,
driftSpeed,
driftAngle,
desaturate,
colorRadius,
background,
});
const optsVersion = useRef(0);
const onProgressRef = useRef(onProgress);
const onImageClickRef = useRef(onImageClick);
useEffect(() => {
const next = {
lensStrength,
lensRadius,
lensMode,
tileSize,
gap,
tileShape,
tileRadius,
tileRotation,
inertia,
dragSpeed,
invertScroll,
autoDrift,
driftSpeed,
driftAngle,
desaturate,
colorRadius,
background,
};
const current = opts.current;
for (const key of Object.keys(next) as (keyof typeof next)[]) {
if (current[key] !== next[key]) {
opts.current = next;
optsVersion.current++;
break;
}
}
onProgressRef.current = onProgress;
onImageClickRef.current = onImageClick;
});
const imagesKey = images.join("|");
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
// A preview harness may portal this into an iframe, where observers built
// from the parent realm measure against the wrong viewport.
const doc = canvas.ownerDocument;
const win = doc.defaultView ?? window;
const gl = canvas.getContext("webgl", {
alpha: true,
premultipliedAlpha: true,
antialias: true,
preserveDrawingBuffer: false,
powerPreference: "high-performance",
});
if (!gl || gl.isContextLost()) {
console.warn("[LensGrid] WebGL unavailable");
return;
}
let frame = 0;
const onContextLost = (e: Event) => {
e.preventDefault();
cancelAnimationFrame(frame);
};
const onContextRestored = () => setEpoch((v) => v + 1);
canvas.addEventListener("webglcontextlost", onContextLost);
canvas.addEventListener("webglcontextrestored", onContextRestored);
const program = buildProgram(gl);
if (!program) {
canvas.removeEventListener("webglcontextlost", onContextLost);
canvas.removeEventListener("webglcontextrestored", onContextRestored);
return;
}
const { data, indices } = buildQuad(SUBDIVISIONS);
const STRIDE = 4 * Float32Array.BYTES_PER_ELEMENT;
const vertexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, vertexBuffer);
gl.bufferData(gl.ARRAY_BUFFER, data, gl.STATIC_DRAW);
const indexBuffer = gl.createBuffer();
gl.bindBuffer(gl.ELEMENT_ARRAY_BUFFER, indexBuffer);
gl.bufferData(gl.ELEMENT_ARRAY_BUFFER, indices, gl.STATIC_DRAW);
const aPosition = gl.getAttribLocation(program, "aPosition");
const aTexCoord = gl.getAttribLocation(program, "aTexCoord");
const u = {
resolution: gl.getUniformLocation(program, "uResolution"),
offset: gl.getUniformLocation(program, "uOffset"),
tilePos: gl.getUniformLocation(program, "uTilePos"),
tileSize: gl.getUniformLocation(program, "uTileSize"),
rotation: gl.getUniformLocation(program, "uRotation"),
lensStrength: gl.getUniformLocation(program, "uLensStrength"),
lensRadius: gl.getUniformLocation(program, "uLensRadius"),
sampler: gl.getUniformLocation(program, "uSampler"),
tileShape: gl.getUniformLocation(program, "uTileShape"),
tileRadius: gl.getUniformLocation(program, "uTileRadius"),
aa: gl.getUniformLocation(program, "uAA"),
desaturate: gl.getUniformLocation(program, "uDesaturate"),
colorRadius: gl.getUniformLocation(program, "uColorRadius"),
};
gl.useProgram(program);
gl.enableVertexAttribArray(aPosition);
gl.vertexAttribPointer(aPosition, 2, gl.FLOAT, false, STRIDE, 0);
gl.enableVertexAttribArray(aTexCoord);
gl.vertexAttribPointer(aTexCoord, 2, gl.FLOAT, false, STRIDE, 8);
gl.enable(gl.BLEND);
gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.activeTexture(gl.TEXTURE0);
gl.uniform1i(u.sampler, 0);
const sources = images;
const textures: (WebGLTexture | null)[] = new Array(sources.length).fill(
null,
);
const loaders: HTMLImageElement[] = [];
let loadedCount = 0;
let disposed = false;
let dirty = true;
sources.forEach((src, i) => {
const img = new Image();
img.crossOrigin = "anonymous";
img.decoding = "async";
loaders.push(img);
img.onload = () => {
if (disposed || gl.isContextLost()) return;
const tex = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, tex);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, img);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
if (isPowerOfTwo(img.naturalWidth) && isPowerOfTwo(img.naturalHeight)) {
gl.texParameteri(
gl.TEXTURE_2D,
gl.TEXTURE_MIN_FILTER,
gl.LINEAR_MIPMAP_LINEAR,
);
gl.generateMipmap(gl.TEXTURE_2D);
} else {
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
}
textures[i] = tex;
loadedCount++;
dirty = true;
onProgressRef.current?.(loadedCount / sources.length);
};
img.onerror = () => {
if (disposed) return;
loadedCount++;
onProgressRef.current?.(loadedCount / sources.length);
};
img.src = src;
});
let ratio = 1;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const cssW = canvas.clientWidth || rect.width || 1;
const cssH = canvas.clientHeight || rect.height || 1;
const visual = rect.width > 0 ? Math.min(rect.width / cssW, 1) : 1;
ratio =
Math.min(win.devicePixelRatio || 1, 2) * Math.max(visual, 0.25);
const w = Math.max(1, Math.round(cssW * ratio));
const h = Math.max(1, Math.round(cssH * ratio));
if (w === canvas.width && h === canvas.height) return;
canvas.width = w;
canvas.height = h;
gl.viewport(0, 0, w, h);
dirty = true;
};
resize();
const resizeObserver = new win.ResizeObserver(resize);
resizeObserver.observe(canvas);
let onScreen = true;
const intersectionObserver = new win.IntersectionObserver(
([entry]) => {
onScreen = entry.isIntersecting;
if (onScreen) resize();
},
{ rootMargin: "100px" },
);
intersectionObserver.observe(canvas);
const view = { x: 0, y: 0 };
const target = { x: 0, y: 0 };
const velocity = { x: 0, y: 0 };
let dragging = false;
let lastX = 0;
let lastY = 0;
let pointerId: number | null = null;
let travelled = 0;
let downAt = 0;
const reduceMotion =
typeof win.matchMedia === "function" &&
win.matchMedia("(prefers-reduced-motion: reduce)").matches;
const strengthOf = (o: typeof opts.current) =>
o.lensMode === "flat"
? 0
: o.lensMode === "pinch"
? -Math.abs(o.lensStrength)
: Math.abs(o.lensStrength);
const hitTest = (clientX: number, clientY: number): LensGridHit | null => {
const count = sources.length;
if (!count) return null;
const o = opts.current;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
const pxW = canvas.width;
const pxH = canvas.height;
const aspect = pxW / pxH;
let dx = (clientX - rect.left) / rect.width - 0.5;
const dy = (clientY - rect.top) / rect.height - 0.5;
dx *= aspect;
const outLen = Math.hypot(dx, dy);
const strength = strengthOf(o);
const radius = Math.max(o.lensRadius, 0.05);
let r = outLen;
if (outLen > 1e-6 && strength !== 0) {
let lo = 0;
let hi = Math.max(outLen * 2, radius * 2);
for (let i = 0; i < 24; i++) {
const mid = (lo + hi) * 0.5;
if (mid * lensScale(mid, radius, strength) < outLen) lo = mid;
else hi = mid;
}
r = (lo + hi) * 0.5;
}
const k = outLen > 1e-6 ? r / outLen : 0;
const planeX = ((dx * k) / aspect + 0.5) * pxW + view.x * ratio;
const planeY = (dy * k + 0.5) * pxH + view.y * ratio;
const step = (o.tileSize + o.gap) * ratio;
const tilePx = o.tileSize * ratio;
const tx = Math.floor(planeX / step);
const ty = Math.floor(planeY / step);
if (planeX - tx * step > tilePx || planeY - ty * step > tilePx) return null;
const index = tileImageIndex(tx, ty, count);
return { index, src: sources[index], clientX, clientY };
};
const onPointerDown = (e: PointerEvent) => {
dragging = true;
pointerId = e.pointerId;
lastX = e.clientX;
lastY = e.clientY;
travelled = 0;
downAt = e.timeStamp;
velocity.x = 0;
velocity.y = 0;
canvas.setPointerCapture(e.pointerId);
};
const onPointerMove = (e: PointerEvent) => {
if (!dragging || e.pointerId !== pointerId) return;
const rawX = e.clientX - lastX;
const rawY = e.clientY - lastY;
travelled += Math.abs(rawX) + Math.abs(rawY);
const speed = opts.current.dragSpeed;
target.x -= rawX * speed;
target.y -= rawY * speed;
velocity.x = -rawX * speed;
velocity.y = -rawY * speed;
lastX = e.clientX;
lastY = e.clientY;
};
const endDrag = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
dragging = false;
pointerId = null;
if (canvas.hasPointerCapture(e.pointerId)) {
canvas.releasePointerCapture(e.pointerId);
}
if (
e.type === "pointerup" &&
travelled < 8 &&
e.timeStamp - downAt < 500 &&
onImageClickRef.current
) {
const hit = hitTest(e.clientX, e.clientY);
if (hit) onImageClickRef.current(hit);
}
};
const onWheel = (e: WheelEvent) => {
e.preventDefault();
const o = opts.current;
const speed = 0.6 * o.dragSpeed * (o.invertScroll ? 1 : -1);
target.x += e.deltaX * speed;
target.y += e.deltaY * speed;
};
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", endDrag);
canvas.addEventListener("pointercancel", endDrag);
canvas.addEventListener("wheel", onWheel, { passive: false });
let lastVersion = -1;
let clearR = 0;
let clearG = 0;
let clearB = 0;
let clearHex = "";
// Any CSS colour, resolved by the browser rather than parsed here, so
// `var(--background)` follows whatever theme the canvas is sitting in.
const setClearColor = (value: string) => {
clearHex = value;
canvas.style.color = "";
canvas.style.color = value;
const resolved = win.getComputedStyle(canvas).color;
const [r = 0, g = 0, b = 0] = resolved
.replace(/[^\d.,]/g, "")
.split(",")
.map(Number);
clearR = r / 255;
clearG = g / 255;
clearB = b / 255;
};
// A theme flip changes what var(--background) resolves to without changing
// the prop, so drop the cached colour when a class lands anywhere above us.
const themeObserver = new win.MutationObserver(() => {
clearHex = "";
dirty = true;
});
themeObserver.observe(doc.documentElement, {
attributes: true,
attributeFilter: ["class"],
subtree: true,
});
const render = () => {
frame = requestAnimationFrame(render);
if (!onScreen || doc.hidden) return;
const o = opts.current;
if (o.background !== clearHex) {
setClearColor(o.background);
dirty = true;
}
if (optsVersion.current !== lastVersion) {
lastVersion = optsVersion.current;
dirty = true;
}
const drifting = !dragging && o.autoDrift && !reduceMotion;
if (drifting) {
const rad = (o.driftAngle * Math.PI) / 180;
target.x += Math.cos(rad) * o.driftSpeed;
target.y += Math.sin(rad) * o.driftSpeed;
}
if (!dragging) {
target.x += velocity.x;
target.y += velocity.y;
const decay = Math.min(Math.max(o.inertia, 0), 0.99);
velocity.x *= decay;
velocity.y *= decay;
if (Math.abs(velocity.x) < 0.01) velocity.x = 0;
if (Math.abs(velocity.y) < 0.01) velocity.y = 0;
}
const dx = target.x - view.x;
const dy = target.y - view.y;
const settled = Math.abs(dx) < 0.01 && Math.abs(dy) < 0.01;
if (settled) {
view.x = target.x;
view.y = target.y;
} else {
view.x += dx * 0.14;
view.y += dy * 0.14;
}
if (settled && !dragging && !drifting && !dirty) return;
dirty = false;
const pxW = canvas.width;
const pxH = canvas.height;
gl.clearColor(clearR, clearG, clearB, 1);
gl.clear(gl.COLOR_BUFFER_BIT);
const strength = strengthOf(o);
const tilePx = o.tileSize * ratio;
gl.uniform2f(u.resolution, pxW, pxH);
gl.uniform2f(u.offset, view.x * ratio, view.y * ratio);
gl.uniform2f(u.tileSize, tilePx, tilePx);
gl.uniform1f(u.lensStrength, strength);
gl.uniform1f(u.lensRadius, Math.max(o.lensRadius, 0.05));
gl.uniform1f(u.tileShape, SHAPE_ID[o.tileShape] ?? 0);
gl.uniform1f(u.tileRadius, Math.min(Math.max(o.tileRadius, 0), 50) / 100);
gl.uniform1f(u.aa, 0.75 / Math.max(tilePx, 1));
gl.uniform1f(u.desaturate, o.desaturate ? 1 : 0);
gl.uniform1f(u.colorRadius, Math.max(o.colorRadius, 0.01));
const step = (o.tileSize + o.gap) * ratio;
const margin = step + Math.abs(strength) * Math.max(pxW, pxH) * 0.5;
const originX = view.x * ratio;
const originY = view.y * ratio;
const firstX = Math.floor((originX - margin) / step);
const lastX = Math.ceil((originX + pxW + margin) / step);
const firstY = Math.floor((originY - margin) / step);
const lastY = Math.ceil((originY + pxH + margin) / step);
const rotation = (o.tileRotation * Math.PI) / 180;
const count = sources.length;
const indexCount = indices.length;
let bound: WebGLTexture | null = null;
for (let ty = firstY; ty <= lastY; ty++) {
const py = ty * step;
for (let tx = firstX; tx <= lastX; tx++) {
const texture = count ? textures[tileImageIndex(tx, ty, count)] : null;
if (!texture) continue;
if (texture !== bound) {
gl.bindTexture(gl.TEXTURE_2D, texture);
bound = texture;
}
gl.uniform2f(u.tilePos, tx * step, py);
gl.uniform1f(u.rotation, (tx + ty) & 1 ? -rotation : rotation);
gl.drawElements(gl.TRIANGLES, indexCount, gl.UNSIGNED_SHORT, 0);
}
}
};
frame = requestAnimationFrame(render);
return () => {
disposed = true;
cancelAnimationFrame(frame);
resizeObserver.disconnect();
intersectionObserver.disconnect();
themeObserver.disconnect();
canvas.removeEventListener("webglcontextlost", onContextLost);
canvas.removeEventListener("webglcontextrestored", onContextRestored);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", endDrag);
canvas.removeEventListener("pointercancel", endDrag);
canvas.removeEventListener("wheel", onWheel);
loaders.forEach((img) => {
img.onload = null;
img.onerror = null;
img.src = "";
});
if (!gl.isContextLost()) {
textures.forEach((t) => t && gl.deleteTexture(t));
gl.deleteBuffer(vertexBuffer);
gl.deleteBuffer(indexBuffer);
gl.deleteProgram(program);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [imagesKey, epoch]);
return (
<canvas
ref={canvasRef}
className={className}
style={{
display: "block",
width: "100%",
height: "100%",
touchAction: "none",
}}
/>
);
}
Keep in mind
Some components here are recreations of the best interactions out there. I don't claim to be the original creator — this is my attempt to study, replicate, and often add a few extra features. I've tried to credit everyone; if I missed something, let me know.
Contact
Found a bug or an issue? Drop a mail — hello@harshpandav.dev
License & Usage
- Free to use and modify in personal and commercial projects.
- Attribution to Harsh Dev UI is appreciated but not required.
- MIT licensed.