Tile field
An endless, pannable field of image tiles on a wrapped mesh pool: grayscale until hovered, seen through a barrel lens with a vignette.
Loading preview…
Show code
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import * as THREE from "three";
import { cn } from "@/lib/utils";
// An endless field of image tiles you pan in any direction. Tiles sit in rows
// of their natural widths, each row phase-shifted so columns never line up,
// and the whole pool wraps on a torus: a fixed set of meshes, repositioned
// every frame, covers an infinite plane. Idle tiles render grayscale; the one
// under the pointer gets its colour back. The frame goes through a barrel
// lens with a hair of chromatic split, then a vignette.
export type CanvasTile = {
id: string;
src: string;
/** Width / height; used until the image loads and reports its own. */
aspect?: number;
title?: string;
};
export type TileFieldParams = {
/** Row height in CSS px. Changing it rebuilds the tile pool. */
tileHeight: number;
/** Gap between tiles in CSS px. Changing it rebuilds the tile pool. */
tileGap: number;
vignetteStrength: number;
vignetteSize: number;
/** Velocity kept per 60Hz frame after a fling (0 = none). */
inertiaFriction: number;
/** How much of the remaining distance is left per 60Hz frame (lower = snappier). */
panEase: number;
/** Barrel distortion amount; also drives the chromatic split. */
barrel: number;
/** Colour tiles under the pointer; off keeps everything grayscale. */
hover: boolean;
};
export const TILE_FIELD_DEFAULTS: TileFieldParams = {
tileHeight: 220,
tileGap: 4,
vignetteStrength: 0.29,
vignetteSize: 1.25,
inertiaFriction: 0.5,
panEase: 0.8,
barrel: 0.04,
hover: true,
};
const CORNER_PX = 4;
const PICK_Z_EPS = 1e-4;
const KEY_STEP = 18;
const quadVert = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
// Rounded-rect SDF mask; luminance grayscale unless hovered.
const tileFrag = /* glsl */ `
varying vec2 vUv;
uniform sampler2D uTex;
uniform float uHover;
uniform vec2 uSizePx;
uniform float uCornerPx;
void main() {
vec2 p = (vUv - 0.5) * uSizePx;
vec2 q = abs(p) - (uSizePx * 0.5 - vec2(uCornerPx)) + uCornerPx;
float d = min(max(q.x, q.y), 0.0) + length(max(q, 0.0)) - uCornerPx;
if (d > max(fwidth(d), 1e-5)) discard;
vec4 c = texture2D(uTex, vUv);
vec3 gray = vec3(dot(c.rgb, vec3(0.299, 0.587, 0.114)));
gl_FragColor = vec4(uHover > 0.5 ? c.rgb : gray, 1.0);
}
`;
const screenVert = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = vec4(position.xy, 0.0, 1.0);
}
`;
const vignetteFrag = /* glsl */ `
varying vec2 vUv;
uniform float uVigStrength;
uniform float uVigSize;
void main() {
float v = smoothstep(0.35, 0.95, length(vUv - 0.5) * uVigSize);
gl_FragColor = vec4(0.0, 0.0, 0.0, v * uVigStrength);
}
`;
const barrelFrag = /* glsl */ `
varying vec2 vUv;
uniform sampler2D uScene;
uniform float uBarrel;
uniform vec3 uBg;
void main() {
vec2 p = vUv - 0.5;
float r2 = dot(p, p);
vec2 distorted = p * (1.0 + uBarrel * r2 + uBarrel * 0.5 * r2 * r2) + 0.5;
vec3 color;
if (distorted.x < 0.0 || distorted.x > 1.0 || distorted.y < 0.0 || distorted.y > 1.0) {
color = uBg;
} else {
float ca = uBarrel * 0.015;
color = vec3(
texture2D(uScene, p * (1.0 + uBarrel * r2 + ca) + 0.5).r,
texture2D(uScene, distorted).g,
texture2D(uScene, p * (1.0 + uBarrel * r2 - ca) + 0.5).b
);
}
gl_FragColor = vec4(color, 1.0);
// Encode for the sRGB canvas; without it every image showed darker than its source.
#include <colorspace_fragment>
}
`;
const mod = (a: number, n: number) => ((a % n) + n) % n;
/** prefix[phase][k] = summed widths of the k tiles before slot k in a row shifted by `phase`. */
function prefixByPhase(widths: number[]) {
const n = widths.length;
return Array.from({ length: n }, (_, phase) => {
const ps = [0];
for (let k = 1; k < n; k++) ps.push(ps[k - 1]! + widths[(k - 1 + phase) % n]!);
return ps;
});
}
function placeholderTexture(): THREE.Texture {
const t = new THREE.DataTexture(new Uint8Array([26, 26, 26, 255]), 1, 1);
t.colorSpace = THREE.SRGBColorSpace;
t.needsUpdate = true;
return t;
}
function prefersReducedMotion() {
return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}
export function TileField({
tiles,
params,
onTileOpen,
wheel = "always",
background = "#000000",
className,
label = "Image canvas",
}: {
tiles: CanvasTile[];
params?: Partial<TileFieldParams>;
onTileOpen?: (tile: CanvasTile) => void;
/**
* "always" takes every wheel event over the canvas (full-screen use).
* "engaged" lets the page scroll until the canvas is clicked or focused,
* so an inline canvas never traps a reader mid-scroll.
*/
wheel?: "always" | "engaged";
/** Clear colour, any CSS colour three.js can parse (hex, rgb, hsl). */
background?: string;
className?: string;
label?: string;
}) {
const wrapRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const P = useMemo(() => ({ ...TILE_FIELD_DEFAULTS, ...params }), [params]);
const paramsRef = useRef(P);
const bgRef = useRef(background);
const openRef = useRef(onTileOpen);
const engagedRef = useRef(wheel === "always");
const [engaged, setEngaged] = useState(wheel === "always");
const [announce, setAnnounce] = useState("");
useEffect(() => {
paramsRef.current = P;
bgRef.current = background;
openRef.current = onTileOpen;
});
useEffect(() => {
engagedRef.current = wheel === "always" ? true : engaged;
}, [wheel, engaged]);
const tilesKey = tiles.map((t) => `${t.id}:${t.src.length}`).join(",");
useEffect(() => {
const root = wrapRef.current;
const canvas = canvasRef.current;
if (!root || !canvas) return;
let disposed = false;
const items = tiles.length ? tiles : [{ id: "empty", src: "" }];
const TILE_H = P.tileHeight;
const GAP = Math.max(0, P.tileGap);
const size = () => ({ w: Math.max(1, root.clientWidth), h: Math.max(1, root.clientHeight) });
// Renderer, scene pass into a render target, then lens + vignette.
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.autoClear = false;
const { w: w0, h: h0 } = size();
renderer.setSize(w0, h0, false);
const buf = renderer.getDrawingBufferSize(new THREE.Vector2());
const target = new THREE.WebGLRenderTarget(buf.x, buf.y, {
magFilter: THREE.LinearFilter,
minFilter: THREE.LinearFilter,
});
const scene = new THREE.Scene();
const bg = new THREE.Color(bgRef.current);
scene.background = bg;
const camera = new THREE.OrthographicCamera(-w0 / 2, w0 / 2, h0 / 2, -h0 / 2, -100, 100);
camera.position.z = 10;
const vignetteMat = new THREE.ShaderMaterial({
vertexShader: screenVert,
fragmentShader: vignetteFrag,
transparent: true,
depthTest: false,
depthWrite: false,
uniforms: { uVigStrength: { value: 0 }, uVigSize: { value: 1 } },
});
const barrelMat = new THREE.ShaderMaterial({
vertexShader: screenVert,
fragmentShader: barrelFrag,
uniforms: { uScene: { value: target.texture }, uBarrel: { value: 0 }, uBg: { value: new THREE.Vector3() } },
});
const screenGeom = new THREE.PlaneGeometry(2, 2);
const vignetteScene = new THREE.Scene().add(new THREE.Mesh(screenGeom, vignetteMat));
const barrelScene = new THREE.Scene().add(new THREE.Mesh(screenGeom, barrelMat));
const screenCam = new THREE.Camera();
// Tile pool: enough rows and strip repeats to cover the frame plus a margin.
let aspects = items.map((t) => Math.min(4, Math.max(0.25, t.aspect ?? 1)));
let widths = aspects.map((a) => TILE_H * a);
let stripW0 = widths.reduce((s, w) => s + w, 0) || TILE_H;
let prefix = prefixByPhase(widths);
const n = items.length;
const stripPool = stripW0 + n * GAP;
const repeats = Math.ceil(w0 / stripPool) + 2;
const rows = Math.ceil(h0 / (TILE_H + GAP)) + 3;
const spanX = repeats * stripPool;
const spanY = rows * (TILE_H + GAP);
const textures = items.map(() => placeholderTexture());
const geom = new THREE.PlaneGeometry(1, 1);
const meshes: THREE.Mesh<THREE.PlaneGeometry, THREE.ShaderMaterial>[] = [];
let index = 0;
for (let j = 0; j < rows; j++) {
const rowPhase = (j * 3) % n;
for (let r = 0; r < repeats; r++) {
for (let k = 0; k < n; k++) {
const texIdx = (k + rowPhase) % n;
const tw = widths[texIdx]!;
const mat = new THREE.ShaderMaterial({
vertexShader: quadVert,
fragmentShader: tileFrag,
uniforms: {
uTex: { value: textures[texIdx] },
uHover: { value: 0 },
uSizePx: { value: new THREE.Vector2(tw, TILE_H) },
uCornerPx: { value: Math.min(CORNER_PX, Math.max(0, Math.min(tw, TILE_H) / 2 - 0.5)) },
},
});
const mesh = new THREE.Mesh(geom, mat);
mesh.scale.set(tw, TILE_H, 1);
mesh.userData = { r, k, j, rowPhase, texIdx, index: index++, tw };
scene.add(mesh);
meshes.push(mesh);
}
}
}
const applyAspects = () => {
widths = aspects.map((a) => TILE_H * a);
stripW0 = widths.reduce((s, w) => s + w, 0) || TILE_H;
prefix = prefixByPhase(widths);
for (const m of meshes) {
const tw = widths[m.userData.texIdx as number]!;
m.scale.set(tw, TILE_H, 1);
m.userData.tw = tw;
m.material.uniforms.uSizePx.value.set(tw, TILE_H);
m.material.uniforms.uCornerPx.value = Math.min(CORNER_PX, Math.max(0, Math.min(tw, TILE_H) / 2 - 0.5));
}
};
// Textures stream in; a tile whose real aspect differs reflows the rows.
const loader = new THREE.TextureLoader();
loader.setCrossOrigin("anonymous");
const loaded: THREE.Texture[] = [];
items.forEach((tile, i) => {
if (!tile.src) return;
loader.load(tile.src, (tex) => {
if (disposed) return tex.dispose();
tex.colorSpace = THREE.SRGBColorSpace;
tex.minFilter = THREE.LinearFilter;
loaded.push(tex);
const img = tex.image as { width?: number; height?: number };
const a = Math.min(4, Math.max(0.25, (img.width || 1) / (img.height || 1)));
if (Math.abs(a - aspects[i]!) > 0.02) {
aspects = aspects.map((v, k) => (k === i ? a : v));
applyAspects();
}
for (const m of meshes) if (m.userData.texIdx === i) m.material.uniforms.uTex.value = tex;
});
});
// Pan state: wheel / drag / keys move the target; offset eases toward it.
let offsetX = 0;
let offsetY = 0;
let targetX = 0;
let targetY = 0;
let velX = 0;
let velY = 0;
let pointerX = 0;
let pointerY = 0;
let pointerInside = false;
let keyboardMode = false;
let hovered = -1;
let lastCentered = -1;
let drag: {
id: number;
lastX: number;
lastY: number;
lastT: number;
startX: number;
startY: number;
moved: boolean;
vx: number;
vy: number;
} | null = null;
const raycaster = new THREE.Raycaster();
const ndc = new THREE.Vector2();
let raf = 0;
let frameCount = 0;
let lastT = performance.now();
// Match the buffer to on-screen pixels: a CSS-scaled canvas (docs
// previews scale it down) shouldn't render more than it shows.
const pixelRatio = () => {
const rect = canvas.getBoundingClientRect();
const k = canvas.clientWidth ? Math.min(1, rect.width / canvas.clientWidth) : 1;
return Math.min(window.devicePixelRatio, 2) * k;
};
const syncSize = () => {
const { w, h } = size();
camera.left = -w / 2;
camera.right = w / 2;
camera.top = h / 2;
camera.bottom = -h / 2;
camera.updateProjectionMatrix();
renderer.setPixelRatio(pixelRatio());
renderer.setSize(w, h, false);
const b = renderer.getDrawingBufferSize(new THREE.Vector2());
target.setSize(b.x, b.y);
};
const tileAt = (idx: number) => {
const m = meshes.find((mesh) => mesh.userData.index === idx);
return m ? items[m.userData.texIdx as number] : undefined;
};
const draw = () => {
if (disposed) return;
const p = paramsRef.current;
// The preview's CSS scale can settle after mount; re-check twice a second.
if (++frameCount % 30 === 0 && Math.abs(pixelRatio() - renderer.getPixelRatio()) > 0.01) syncSize();
const reduced = prefersReducedMotion();
const now = performance.now();
const dt = Math.min(now - lastT, 50);
lastT = now;
const { w, h } = size();
const frames = dt / 16.67;
bg.set(bgRef.current);
targetX += velX;
targetY += velY;
const friction = reduced ? 0 : p.inertiaFriction;
velX *= Math.pow(friction, frames);
velY *= Math.pow(friction, frames);
if (Math.abs(velX) < 0.01) velX = 0;
if (Math.abs(velY) < 0.01) velY = 0;
const ease = reduced ? 1 : 1 - Math.pow(p.panEase, frames);
offsetX += (targetX - offsetX) * ease;
offsetY += (targetY - offsetY) * ease;
// Torus wrap. The period never drops below the pool's span, or two
// meshes would land on the same spot.
const stripW = stripW0 + n * GAP;
const cellH = TILE_H + GAP;
const totalW = Math.max(stripW, w + stripW);
const totalH = (Math.ceil(h / cellH) + 3) * cellH;
const periodX = Math.max(stripW * Math.ceil(totalW / stripW), spanX);
const periodY = Math.max(totalH, spanY);
for (const m of meshes) {
const u = m.userData as { r: number; k: number; j: number; rowPhase: number; tw: number; index: number };
const bx = u.r * stripW + prefix[u.rowPhase]![u.k]! + u.k * GAP + u.tw / 2;
const by = u.j * cellH;
const x = mod(bx - totalW / 2 + offsetX + totalW / 2, periodX) - totalW / 2;
const y = mod(by - totalH / 2 + offsetY + totalH / 2, periodY) - totalH / 2;
m.position.set(x, y, -u.index * PICK_Z_EPS);
}
// Pick under the pointer, or under the centre while using the keyboard.
hovered = -1;
if (p.hover && (pointerInside || keyboardMode)) {
ndc.set(keyboardMode ? 0 : (pointerX / w) * 2 - 1, keyboardMode ? 0 : -(pointerY / h) * 2 + 1);
raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObjects(meshes, false)[0];
if (hit) hovered = hit.object.userData.index as number;
}
for (const m of meshes) m.material.uniforms.uHover.value = m.userData.index === hovered ? 1 : 0;
if (keyboardMode && hovered !== lastCentered) {
lastCentered = hovered;
const t = tileAt(hovered);
if (t?.title) setAnnounce(t.title);
}
vignetteMat.uniforms.uVigStrength.value = p.vignetteStrength;
vignetteMat.uniforms.uVigSize.value = p.vignetteSize;
barrelMat.uniforms.uBarrel.value = p.barrel;
barrelMat.uniforms.uBg.value.set(bg.r, bg.g, bg.b);
renderer.setRenderTarget(target);
renderer.clear();
renderer.render(scene, camera);
renderer.render(vignetteScene, screenCam);
renderer.setRenderTarget(null);
renderer.clear();
renderer.render(barrelScene, screenCam);
raf = requestAnimationFrame(draw);
};
// Pointer position in layout px. The canvas may be CSS-scaled (a docs
// preview scales it down from its native width), so divide the scale out.
const local = (e: { clientX: number; clientY: number }) => {
const rect = canvas.getBoundingClientRect();
const k = rect.width ? canvas.clientWidth / rect.width : 1;
pointerX = (e.clientX - rect.left) * k;
pointerY = (e.clientY - rect.top) * k;
};
const open = () => {
const t = tileAt(hovered);
if (t && t.src) openRef.current?.(t);
};
const onPointerMove = (e: PointerEvent) => {
pointerInside = true;
keyboardMode = false;
local(e);
if (!drag || drag.id !== e.pointerId) return;
const dx = pointerX - drag.lastX;
const dy = pointerY - drag.lastY;
const now = performance.now();
const frames = Math.max((now - drag.lastT) / 16.67, 0.25);
drag.lastX = pointerX;
drag.lastY = pointerY;
drag.lastT = now;
// 1:1 under the hand: move the offset itself, not just the eased target.
targetX += dx;
targetY -= dy;
offsetX += dx;
offsetY -= dy;
drag.vx = dx / frames;
drag.vy = -dy / frames;
if (Math.hypot(pointerX - drag.startX, pointerY - drag.startY) > 6) drag.moved = true;
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType === "mouse" && e.button !== 0) return;
local(e);
pointerInside = true;
setEngaged(true);
velX = 0;
velY = 0;
targetX = offsetX;
targetY = offsetY;
drag = {
id: e.pointerId,
lastX: pointerX,
lastY: pointerY,
lastT: performance.now(),
startX: pointerX,
startY: pointerY,
moved: false,
vx: 0,
vy: 0,
};
try {
canvas.setPointerCapture(e.pointerId);
} catch {}
};
const onPointerUp = (e: PointerEvent) => {
if (!drag || drag.id !== e.pointerId) return;
const { moved, vx, vy, lastT } = drag;
drag = null;
// Release momentum: carry the last pointer velocity unless the hand had stopped.
if (moved && performance.now() - lastT < 80 && !prefersReducedMotion()) {
velX = vx;
velY = vy;
}
try {
canvas.releasePointerCapture(e.pointerId);
} catch {}
if (!moved) open();
};
const onPointerCancel = () => {
drag = null;
};
const onPointerLeave = () => {
pointerInside = false;
if (wheel === "engaged") setEngaged(false);
};
const onWheel = (e: WheelEvent) => {
if (!engagedRef.current) return;
e.preventDefault();
pointerInside = true;
keyboardMode = false;
local(e);
targetX -= e.deltaX || 0;
targetY += e.deltaY || 0;
};
const onKeyDown = (e: KeyboardEvent) => {
const step = KEY_STEP * (e.shiftKey ? 3 : 1);
const moves: Record<string, [number, number]> = {
ArrowLeft: [step, 0],
ArrowRight: [-step, 0],
ArrowUp: [0, -step],
ArrowDown: [0, step],
};
const mv = moves[e.key];
if (mv) {
e.preventDefault();
keyboardMode = true;
velX = mv[0];
velY = mv[1];
} else if (e.key === "Enter" && keyboardMode) {
e.preventDefault();
open();
}
};
canvas.addEventListener("wheel", onWheel, { passive: false });
canvas.addEventListener("pointerdown", onPointerDown);
canvas.addEventListener("pointermove", onPointerMove);
canvas.addEventListener("pointerup", onPointerUp);
canvas.addEventListener("pointercancel", onPointerCancel);
canvas.addEventListener("pointerleave", onPointerLeave);
root.addEventListener("keydown", onKeyDown);
const ro = new ResizeObserver(syncSize);
ro.observe(root);
window.addEventListener("resize", syncSize);
syncSize();
draw();
return () => {
disposed = true;
cancelAnimationFrame(raf);
ro.disconnect();
window.removeEventListener("resize", syncSize);
canvas.removeEventListener("wheel", onWheel);
canvas.removeEventListener("pointerdown", onPointerDown);
canvas.removeEventListener("pointermove", onPointerMove);
canvas.removeEventListener("pointerup", onPointerUp);
canvas.removeEventListener("pointercancel", onPointerCancel);
canvas.removeEventListener("pointerleave", onPointerLeave);
root.removeEventListener("keydown", onKeyDown);
for (const m of meshes) m.material.dispose();
geom.dispose();
screenGeom.dispose();
vignetteMat.dispose();
barrelMat.dispose();
textures.forEach((t) => t.dispose());
loaded.forEach((t) => t.dispose());
target.dispose();
renderer.dispose();
};
// Rebuild only when the tiles or the pool geometry change.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tilesKey, P.tileHeight, P.tileGap, wheel]);
return (
<div
ref={wrapRef}
role="group"
aria-roledescription="canvas"
aria-label={`${label}. Drag or use arrow keys to pan; Enter opens the centred tile.`}
tabIndex={0}
onFocus={() => setEngaged(true)}
onBlur={() => wheel === "engaged" && setEngaged(false)}
className={cn(
"relative isolate overflow-hidden rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring",
className,
)}
>
<canvas ref={canvasRef} aria-hidden className="block size-full cursor-grab touch-none active:cursor-grabbing" />
<p aria-live="polite" className="sr-only">
{announce}
</p>
</div>
);
}