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.

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>
  );
}