0%
Drag or scroll to roam · click a tile · Photos by Dynamic Wang

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.

lensModebulge · pinch · flat — default "bulge".
lensStrength0–1.2 — default 0.45.
lensRadius0.4–2 — default 1.1.
tileSize100–280px — default 180px.
gap0–80px — default 6px.
tileShapesquare · circle · hexagon · diamond · triangle — default "square".
tileRadius0–50% — default 6%.
tileRotation0–20° — default 0°.
inertia0.85–0.98 — default 0.95.
dragSpeed0.4–2 — default 1.
invertScrollon / off — default off.
clickToPreviewon / off — default on.
autoDrifton / off — default on.
driftSpeed0.05–1 — default 0.25.
desaturateon / off — default off.
colorRadius0.2–1 — default 0.55.
backgroundcolour — default var(--background).

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-grid

How 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

imagesstring[] — the image URLs tiled across the plane. Required. They repeat forever in both axes, so 20–40 is plenty.
lensMode"bulge" (default) magnifies the centre, "pinch" shrinks it, "flat" turns the distortion off and leaves the infinite grid.
lensStrengthHow hard the lens bends, 0–1.2. Default 0.45.
lensRadiusHow much of the screen the lens covers, in screen-heights. Smaller is a tighter magnifier. Default 1.1.
tileSizeTile edge in CSS pixels. Default 180.
gapSpace between tiles in px. Default 6.
tileShape"square" (default), "circle", "hexagon", "diamond" or "triangle". Each is a signed distance field in the fragment shader, so the silhouette stays crisp at any tile size.
tileRadiusCorner rounding, 0–50. Works on every shape, not just squares — rounding a distance field is a subtraction, so a hexagon softens the same way a square does. Default 6.
tileRotationPer-tile tilt in degrees. Neighbours lean opposite ways so the grid looks hand-placed. Default 0.
inertiaHow long a flick keeps gliding, 0–0.99. Higher glides further. Default 0.95.
dragSpeedMultiplier on drag and wheel movement. Default 1.
invertScrollFlips the wheel direction. Off (default) means the wheel pushes the wall the same way dragging would; on gives page-scroll semantics instead. Default false.
autoDriftDrift slowly on its own while nobody is touching it. Ignored when prefers-reduced-motion is set. Default true.
driftSpeedDrift speed in px per frame. Default 0.25.
driftAngleDrift direction in degrees. Default 35.
desaturateGrey out everything outside the lens so the focus pops. Default false.
colorRadiusWhere colour gives way to grey, as a fraction of the lens radius. Default 0.55.
backgroundCanvas clear colour as a hex string. Default "#000000".
onProgress(progress: number) => void — fires 0 → 1 as textures land, for your own loading veil.
onImageClick(hit: { index, src, clientX, clientY }) => void — fires when a tile is clicked rather than dragged. Build whatever preview you like on top; the component stays presentational.

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.

Source code
"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.

Command Palette

Search for a command to run...