Skip to content

Infinite Canvas Gallery

An endless pannable grid with momentum and column parallax.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/infinite-canvas-gallery.json

Manual

  1. Install the dependencies

    npm install clsx lucide-react motion tailwind-merge
  2. Add the shared helpers

    Components use cn() from lib/utils.ts and the motion tokens below. See Installation if you have not set them up yet.

    lib/gallery/gallery-a11y.tsx
    import { useEffect, useRef } from "react";
    import type { GalleryItem } from "@/lib/gallery/types";
    
    export interface GalleryA11yProps {
      items: GalleryItem[];
      /** Active item. */
      index: number;
      /** Names the list, e.g. "Album covers". */
      label: string;
      /** Focusing an item's button (Tab, screen reader cursor) moves the gallery to it. */
      onFocusItem?: (index: number) => void;
      /** Clicking an item's button or pressing Enter on it. */
      onActivate?: (index: number) => void;
      /** Announce index changes politely. Turn off while autoplay runs so it doesn't talk over the user. @default true */
      live?: boolean;
    }
    
    const describe = (item: GalleryItem, index: number, total: number) =>
      [`${index + 1} of ${total}`, item.title, item.subtitle, item.alt].filter(Boolean).join(". ");
    
    /**
     * The accessible side of a canvas gallery: a visually hidden list of buttons, one per item, kept
     * in sync with the active index (roving tabindex, focus follows arrow keys), plus a polite live
     * region that names the active item. The canvas itself stays aria-hidden.
     */
    export function GalleryA11y({ items, index, label, onFocusItem, onActivate, live = true }: GalleryA11yProps) {
      const list = useRef<HTMLUListElement>(null);
    
      // When the active item changes while focus is in the list (arrow keys), move focus with it.
      useEffect(() => {
        const element = list.current;
        if (!element || !element.contains(document.activeElement)) return;
        element.querySelectorAll<HTMLButtonElement>("button")[index]?.focus({ preventScroll: true });
      }, [index]);
    
      const active = items[index];
      return (
        <div className="sr-only">
          <ul ref={list} aria-label={label}>
            {items.map((item, i) => (
              <li key={`${item.src}-${i}`}>
                <button
                  type="button"
                  tabIndex={i === index ? 0 : -1}
                  aria-current={i === index ? "true" : undefined}
                  onFocus={() => {
                    if (i !== index) onFocusItem?.(i);
                  }}
                  onClick={() => onActivate?.(i)}
                >
                  {describe(item, i, items.length)}
                </button>
              </li>
            ))}
          </ul>
          <p aria-live={live ? "polite" : "off"} aria-atomic="true">
            {active ? `${active.title ?? active.alt}, ${index + 1} of ${items.length}` : ""}
          </p>
        </div>
      );
    }
    lib/gallery/gallery-chrome.tsx
    import { useState } from "react";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ChevronLeft, ChevronRight } from "lucide-react";
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import type { GalleryItem } from "@/lib/gallery/types";
    
    /** "light" = a light (or transparent) background that needs dark text; "dark" = a dark one. */
    export type ChromeScheme = "light" | "dark";
    
    /**
     * Picks caption and control colors for a background. Understands hex and rgb()/rgba(); anything
     * else (named colors, var()) and transparent backgrounds count as light, like the page itself.
     */
    export function chromeScheme(background?: string): ChromeScheme {
      if (!background) return "light";
      const value = background.trim().toLowerCase();
      let rgb: number[] | null = null;
      let alpha = 1;
      const hex = value.match(/^#([0-9a-f]{3,8})$/);
      if (hex) {
        const h = hex[1];
        const full = h.length <= 4 ? [...h].map((c) => c + c).join("") : h;
        rgb = [0, 2, 4].map((i) => parseInt(full.slice(i, i + 2), 16));
        if (full.length === 8) alpha = parseInt(full.slice(6, 8), 16) / 255;
      }
      const fn = value.match(/^rgba?\(([^)]+)\)$/);
      if (fn) {
        const parts = fn[1].split(/[\s,/]+/).filter(Boolean).map(parseFloat);
        rgb = parts.slice(0, 3);
        if (parts.length > 3) alpha = parts[3] > 1 ? parts[3] / 100 : parts[3];
      }
      if (!rgb || alpha < 0.5) return "light";
      const [r, g, b] = rgb.map((c) => {
        const s = c / 255;
        return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * r + 0.7152 * g + 0.0722 * b < 0.25 ? "dark" : "light";
    }
    
    export interface GalleryCaptionProps {
      item: GalleryItem | undefined;
      index: number;
      /** Item count, so wrapping from last to first slides the right way. */
      count: number;
      scheme?: ChromeScheme;
      className?: string;
    }
    
    /**
     * Title and subtitle of the active item. It slides in from the direction of travel. Screen readers
     * get the same text from the gallery's live region, so this copy is aria-hidden.
     */
    export function GalleryCaption({ item, index, count, scheme = "light", className }: GalleryCaptionProps) {
      const reduceMotion = useReducedMotion();
      const [previous, setPrevious] = useState(index);
      const [direction, setDirection] = useState(1);
      if (previous !== index) {
        let delta = index - previous;
        if (count > 2 && Math.abs(delta) > count / 2) delta -= Math.sign(delta) * count;
        setDirection(delta >= 0 ? 1 : -1);
        setPrevious(index);
      }
      const shift = reduceMotion ? 0 : 14;
      return (
        <div aria-hidden="true" className={cn("relative h-10 min-w-0 overflow-hidden", className)}>
          <AnimatePresence initial={false} custom={direction} mode="popLayout">
            <motion.div
              key={index}
              custom={direction}
              variants={{
                enter: (d: number) => ({ opacity: 0, x: shift * d, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }),
                center: { opacity: 1, x: 0, filter: "blur(0px)" },
                exit: (d: number) => ({ opacity: 0, x: -shift * d, filter: reduceMotion ? "blur(0px)" : "blur(4px)" }),
              }}
              initial="enter"
              animate="center"
              exit="exit"
              transition={{ duration: duration.base, ease: ease.out }}
              className="absolute inset-0"
            >
              <p className={cn("truncate text-sm font-medium", scheme === "dark" ? "text-white" : "text-foreground")}>{item?.title ?? item?.alt}</p>
              {item?.subtitle ? <p className={cn("truncate text-[13px]", scheme === "dark" ? "text-white/65" : "text-muted-foreground")}>{item.subtitle}</p> : null}
            </motion.div>
          </AnimatePresence>
        </div>
      );
    }
    
    export interface GalleryControlsProps {
      index: number;
      count: number;
      onPrevious: () => void;
      onNext: () => void;
      scheme?: ChromeScheme;
      /** Disable the buttons at the ends of a gallery that doesn't loop. @default false */
      bounded?: boolean;
      className?: string;
    }
    
    /** Previous / next buttons with an "n / total" counter. */
    export function GalleryControls({ index, count, onPrevious, onNext, scheme = "light", bounded = false, className }: GalleryControlsProps) {
      const button = cn(
        "grid size-10 cursor-pointer place-items-center rounded-full border transition-[background-color,opacity,transform] duration-150 outline-none active:scale-[0.94] disabled:pointer-events-none disabled:opacity-35 focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
        scheme === "dark" ? "border-white/20 bg-white/10 text-white hover:bg-white/20" : "border-border bg-background/80 text-foreground hover:bg-surface-2",
      );
      return (
        <div data-gallery-control className={cn("flex shrink-0 items-center gap-1.5", className)}>
          <button type="button" data-gallery-control aria-label="Previous" onClick={onPrevious} disabled={bounded && index <= 0} className={button}>
            <ChevronLeft className="size-4" aria-hidden="true" />
          </button>
          <span aria-hidden="true" className={cn("min-w-14 text-center font-mono text-[13px] tabular-nums", scheme === "dark" ? "text-white/75" : "text-muted-foreground")}>
            {index + 1} / {count}
          </span>
          <button type="button" data-gallery-control aria-label="Next" onClick={onNext} disabled={bounded && index >= count - 1} className={button}>
            <ChevronRight className="size-4" aria-hidden="true" />
          </button>
        </div>
      );
    }
    lib/gallery/gallery-fallback.tsx
    import { cn } from "@/lib/utils";
    import type { GalleryItem } from "@/lib/gallery/types";
    
    export interface GalleryFallbackProps {
      items: GalleryItem[];
      /** Names the list. */
      label: string;
      /** CSS aspect ratio of each image. @default "4 / 5" */
      aspect?: string;
      onSelect?: (item: GalleryItem, index: number) => void;
      className?: string;
    }
    
    /** What a gallery shows without WebGL: the same items as a plain, swipeable scroll-snap list. */
    export function GalleryFallback({ items, label, aspect = "4 / 5", onSelect, className }: GalleryFallbackProps) {
      return (
        <ul aria-label={label} className={cn("flex size-full snap-x snap-mandatory items-center gap-4 overflow-x-auto px-6 py-6 [scrollbar-width:thin]", className)}>
          {items.map((item, i) => (
            <li key={`${item.src}-${i}`} className="w-[min(70%,15rem)] shrink-0 snap-center">
              <figure>
                {onSelect ? (
                  <button type="button" onClick={() => onSelect(item, i)} className="block w-full cursor-pointer rounded-xl outline-none focus-visible:ring-2 focus-visible:ring-ring">
                    {/* eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic */}
                    <img src={item.src} alt={item.alt} loading="lazy" decoding="async" className="w-full rounded-xl object-cover shadow-md" style={{ aspectRatio: aspect }} />
                  </button>
                ) : (
                  // eslint-disable-next-line @next/next/no-img-element -- plain img keeps the component framework-agnostic
                  <img src={item.src} alt={item.alt} loading="lazy" decoding="async" className="w-full rounded-xl object-cover shadow-md" style={{ aspectRatio: aspect }} />
                )}
                {item.title ? (
                  <figcaption className="mt-2 text-sm">
                    <span className="font-medium text-foreground">{item.title}</span>
                    {item.subtitle ? <span className="block text-muted-foreground">{item.subtitle}</span> : null}
                  </figcaption>
                ) : null}
              </figure>
            </li>
          ))}
        </ul>
      );
    }
    lib/gallery/math.ts
    /** Column-major 4×4 matrices for the gallery renderer (no dependencies). */
    export type Mat4 = Float32Array;
    
    export const createMat4 = (): Mat4 => {
      const m = new Float32Array(16);
      m[0] = m[5] = m[10] = m[15] = 1;
      return m;
    };
    
    /** out = a × b */
    export function multiply(out: Mat4, a: Mat4, b: Mat4) {
      for (let col = 0; col < 4; col++) {
        const b0 = b[col * 4];
        const b1 = b[col * 4 + 1];
        const b2 = b[col * 4 + 2];
        const b3 = b[col * 4 + 3];
        out[col * 4] = a[0] * b0 + a[4] * b1 + a[8] * b2 + a[12] * b3;
        out[col * 4 + 1] = a[1] * b0 + a[5] * b1 + a[9] * b2 + a[13] * b3;
        out[col * 4 + 2] = a[2] * b0 + a[6] * b1 + a[10] * b2 + a[14] * b3;
        out[col * 4 + 3] = a[3] * b0 + a[7] * b1 + a[11] * b2 + a[15] * b3;
      }
      return out;
    }
    
    export function perspective(out: Mat4, fovy: number, aspect: number, near: number, far: number) {
      const f = 1 / Math.tan(fovy / 2);
      out.fill(0);
      out[0] = f / aspect;
      out[5] = f;
      out[10] = (far + near) / (near - far);
      out[11] = -1;
      out[14] = (2 * far * near) / (near - far);
      return out;
    }
    
    export function lookAt(out: Mat4, eye: readonly number[], target: readonly number[], up: readonly number[] = [0, 1, 0]) {
      let zx = eye[0] - target[0];
      let zy = eye[1] - target[1];
      let zz = eye[2] - target[2];
      let len = Math.hypot(zx, zy, zz) || 1;
      zx /= len;
      zy /= len;
      zz /= len;
      let xx = up[1] * zz - up[2] * zy;
      let xy = up[2] * zx - up[0] * zz;
      let xz = up[0] * zy - up[1] * zx;
      len = Math.hypot(xx, xy, xz) || 1;
      xx /= len;
      xy /= len;
      xz /= len;
      const yx = zy * xz - zz * xy;
      const yy = zz * xx - zx * xz;
      const yz = zx * xy - zy * xx;
      out[0] = xx;
      out[1] = yx;
      out[2] = zx;
      out[3] = 0;
      out[4] = xy;
      out[5] = yy;
      out[6] = zy;
      out[7] = 0;
      out[8] = xz;
      out[9] = yz;
      out[10] = zz;
      out[11] = 0;
      out[12] = -(xx * eye[0] + xy * eye[1] + xz * eye[2]);
      out[13] = -(yx * eye[0] + yy * eye[1] + yz * eye[2]);
      out[14] = -(zx * eye[0] + zy * eye[1] + zz * eye[2]);
      out[15] = 1;
      return out;
    }
    
    /**
     * Model matrix: translate × rotateY × rotateX × rotateZ × flip × uniform scale. Angles in radians.
     * `flip` turns the card around its own vertical axis before the other rotations (turning a leaning card over).
     */
    export function compose(out: Mat4, x: number, y: number, z: number, rx: number, ry: number, rz: number, s: number, flip = 0) {
      const cx = Math.cos(rx);
      const sx = Math.sin(rx);
      const cy = Math.cos(ry);
      const sy = Math.sin(ry);
      const cz = Math.cos(rz);
      const sz = Math.sin(rz);
      out[0] = (cy * cz + sy * sx * sz) * s;
      out[1] = cx * sz * s;
      out[2] = (-sy * cz + cy * sx * sz) * s;
      out[3] = 0;
      out[4] = (-cy * sz + sy * sx * cz) * s;
      out[5] = cx * cz * s;
      out[6] = (sy * sz + cy * sx * cz) * s;
      out[7] = 0;
      out[8] = sy * cx * s;
      out[9] = -sx * s;
      out[10] = cy * cx * s;
      out[11] = 0;
      if (flip) {
        // Right-multiply by a rotation about local y: column 0 and column 2 mix.
        const cf = Math.cos(flip);
        const sf = Math.sin(flip);
        for (let row = 0; row < 3; row++) {
          const c0 = out[row];
          const c2 = out[8 + row];
          out[row] = c0 * cf - c2 * sf;
          out[8 + row] = c0 * sf + c2 * cf;
        }
      }
      out[12] = x;
      out[13] = y;
      out[14] = z;
      out[15] = 1;
      return out;
    }
    
    /** Inverts m into out. Returns false (leaving out untouched) when m is singular. */
    export function invert(out: Mat4, m: Mat4) {
      const [a00, a01, a02, a03, a10, a11, a12, a13, a20, a21, a22, a23, a30, a31, a32, a33] = m;
      const b00 = a00 * a11 - a01 * a10;
      const b01 = a00 * a12 - a02 * a10;
      const b02 = a00 * a13 - a03 * a10;
      const b03 = a01 * a12 - a02 * a11;
      const b04 = a01 * a13 - a03 * a11;
      const b05 = a02 * a13 - a03 * a12;
      const b06 = a20 * a31 - a21 * a30;
      const b07 = a20 * a32 - a22 * a30;
      const b08 = a20 * a33 - a23 * a30;
      const b09 = a21 * a32 - a22 * a31;
      const b10 = a21 * a33 - a23 * a31;
      const b11 = a22 * a33 - a23 * a32;
      let det = b00 * b11 - b01 * b10 + b02 * b09 + b03 * b08 - b04 * b07 + b05 * b06;
      if (!det) return false;
      det = 1 / det;
      out[0] = (a11 * b11 - a12 * b10 + a13 * b09) * det;
      out[1] = (a02 * b10 - a01 * b11 - a03 * b09) * det;
      out[2] = (a31 * b05 - a32 * b04 + a33 * b03) * det;
      out[3] = (a22 * b04 - a21 * b05 - a23 * b03) * det;
      out[4] = (a12 * b08 - a10 * b11 - a13 * b07) * det;
      out[5] = (a00 * b11 - a02 * b08 + a03 * b07) * det;
      out[6] = (a32 * b02 - a30 * b05 - a33 * b01) * det;
      out[7] = (a20 * b05 - a22 * b02 + a23 * b01) * det;
      out[8] = (a10 * b10 - a11 * b08 + a13 * b06) * det;
      out[9] = (a01 * b08 - a00 * b10 - a03 * b06) * det;
      out[10] = (a30 * b04 - a31 * b02 + a33 * b00) * det;
      out[11] = (a21 * b02 - a20 * b04 - a23 * b00) * det;
      out[12] = (a11 * b07 - a10 * b09 - a12 * b06) * det;
      out[13] = (a00 * b09 - a01 * b07 + a02 * b06) * det;
      out[14] = (a31 * b01 - a30 * b03 - a32 * b00) * det;
      out[15] = (a20 * b03 - a21 * b01 + a22 * b00) * det;
      return true;
    }
    
    /** m × (x, y, z, w) */
    export function transform(m: Mat4, x: number, y: number, z: number, w = 1): [number, number, number, number] {
      return [
        m[0] * x + m[4] * y + m[8] * z + m[12] * w,
        m[1] * x + m[5] * y + m[9] * z + m[13] * w,
        m[2] * x + m[6] * y + m[10] * z + m[14] * w,
        m[3] * x + m[7] * y + m[11] * z + m[15] * w,
      ];
    }
    
    export const clamp = (value: number, min: number, max: number) => (value < min ? min : value > max ? max : value);
    export const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
    export const smoothstep = (edge0: number, edge1: number, x: number) => {
      const t = clamp((x - edge0) / (edge1 - edge0), 0, 1);
      return t * t * (3 - 2 * t);
    };
    /** Positive modulo, e.g. for wrapping indexes. */
    export const wrap = (value: number, length: number) => ((value % length) + length) % length;
    lib/gallery/renderer.ts
    import { clamp, compose, createMat4, invert, lookAt, multiply, perspective, transform, type Mat4 } from "@/lib/gallery/math";
    
    /**
     * One textured card in world space. Units are CSS pixels: with the default camera, a card at
     * z = 0 that is 200 wide covers 200 CSS pixels on screen. Angles are radians; y points up.
     */
    export interface GalleryCard {
      /** Index into the renderer's image list, or -1 for an untextured card. */
      image: number;
      x: number;
      y: number;
      z: number;
      rotateX?: number;
      rotateY?: number;
      rotateZ?: number;
      /** Uniform scale. @default 1 */
      scale?: number;
      /** Turn around the card's own vertical axis, applied before the other rotations (radians). @default 0 */
      flip?: number;
      width: number;
      height: number;
      /** Corner radius in px. @default 12 */
      radius?: number;
      /** 0 keeps `radius`, 1 rounds fully (circle for square cards). @default 0 */
      round?: number;
      /** Fades the card toward the background. @default 1 */
      opacity?: number;
      /** Darkens the card (0–1). @default 0 */
      dim?: number;
      /** 0 = monochrome and low contrast, 1 = full color (develop effect). @default 1 */
      tone?: number;
      /** Soft drop shadow strength (0–1). @default 0 */
      shadow?: number;
      /** Floor reflection strength (0–1); needs `floorY` in the scene. @default 0 */
      reflection?: number;
      /** Accent ring strength (0–1). @default 0 */
      ring?: number;
      /** Paper border in px around the image (print look). @default 0 */
      border?: number;
      /** Shifts the cover crop, -1 to 1 per axis (parallax drift inside the frame). @default 0 */
      panX?: number;
      panY?: number;
      /** Flat color mixed over the card, [r, g, b, amount] in 0–1 (e.g. a card's back). */
      fill?: readonly [number, number, number, number];
      /** Returned by `pick`. @default the card's position in the list */
      id?: number;
    }
    
    export interface GalleryScene {
      /** Vertical field of view in degrees. @default 30 */
      fov?: number;
      /** Camera position and target. The default camera sits on +z so that z = 0 maps 1:1 to CSS px. */
      camera?: { x?: number; y?: number; z?: number; targetX?: number; targetY?: number; targetZ?: number };
      /** World y of the reflection floor. */
      floorY?: number;
      /** Distance below the floor over which reflections fade out, px. @default 160 */
      reflectionFade?: number;
      /** Shadow offset (down) and blur in px. @default 14 and 22 */
      shadowOffset?: number;
      shadowBlur?: number;
      /** Shadow opacity at strength 1. @default 0.28 */
      shadowOpacity?: number;
      /** Fades everything toward the left/right and top/bottom edges, as a fraction of the size. */
      edgeFade?: readonly [number, number];
      /** Colors as [r, g, b] in 0–1. */
      placeholder?: readonly [number, number, number];
      paper?: readonly [number, number, number];
      ring?: readonly [number, number, number];
    }
    
    export interface PickResult {
      id: number;
      /** Hit point in the card's local space, 0–1 from the bottom-left corner. */
      u: number;
      v: number;
    }
    
    const FLOATS = 40;
    const KIND_CARD = 0;
    const KIND_SHADOW = 1;
    const KIND_REFLECTION = 2;
    
    const VERTEX = /* glsl */ `#version 300 es
    layout(location = 0) in vec2 a_corner;
    layout(location = 1) in mat4 a_model;
    layout(location = 5) in vec4 a_size;
    layout(location = 6) in vec4 a_look;
    layout(location = 7) in vec4 a_extra;
    layout(location = 8) in vec4 a_meta;
    layout(location = 9) in vec4 a_uv;
    layout(location = 10) in vec4 a_fill;
    uniform mat4 u_viewProj;
    uniform float u_floorY;
    uniform float u_shadowOffset;
    uniform float u_shadowBlur;
    out vec2 v_local;
    out vec2 v_uv;
    out float v_worldY;
    flat out vec4 v_size;
    flat out vec4 v_look;
    flat out vec4 v_extra;
    flat out vec4 v_meta;
    flat out vec4 v_uvRect;
    flat out vec4 v_fill;
    void main() {
      float kind = a_meta.x;
      bool shadow = kind > 0.5 && kind < 1.5;
      // Grow the quad past the card so edges antialias fully and shadows have room to blur.
      float margin = shadow ? u_shadowBlur * 2.0 : 2.0;
      vec2 local = a_corner * (a_size.xy + margin * 2.0);
      vec4 world = a_model * vec4(local, 0.0, 1.0);
      if (shadow) { world.y -= u_shadowOffset; world.z -= 0.5; }
      if (kind > 1.5) world.y = 2.0 * u_floorY - world.y;
      v_worldY = world.y;
      v_local = local;
      v_uv = local / a_size.xy + 0.5;
      v_size = a_size;
      v_look = a_look;
      v_extra = a_extra;
      v_meta = a_meta;
      v_uvRect = a_uv;
      v_fill = a_fill;
      gl_Position = u_viewProj * world;
    }`;
    
    const FRAGMENT = /* glsl */ `#version 300 es
    precision highp float;
    precision highp sampler2DArray;
    uniform sampler2DArray u_images;
    uniform vec2 u_resolution;
    uniform vec2 u_edgeFade;
    uniform vec3 u_placeholder;
    uniform vec3 u_paper;
    uniform vec3 u_ring;
    uniform float u_floorY;
    uniform float u_reflectFade;
    uniform float u_shadowBlur;
    uniform float u_shadowOpacity;
    in vec2 v_local;
    in vec2 v_uv;
    in float v_worldY;
    flat in vec4 v_size;   // width, height, radius, round
    flat in vec4 v_look;   // opacity, dim, tone, ring
    flat in vec4 v_extra;  // layer, loaded, border, shadow
    flat in vec4 v_meta;   // kind, reflection
    flat in vec4 v_uvRect; // u0, v0, u1, v1 inside the texture layer
    flat in vec4 v_fill;   // r, g, b, amount
    out vec4 fragColor;
    
    float roundRect(vec2 p, vec2 halfSize, float r) {
      vec2 q = abs(p) - halfSize + r;
      return length(max(q, 0.0)) + min(max(q.x, q.y), 0.0) - r;
    }
    
    void main() {
      vec2 halfSize = v_size.xy * 0.5;
      float maxRadius = min(halfSize.x, halfSize.y);
      float radius = mix(min(v_size.z, maxRadius), maxRadius, v_size.w);
      float d = roundRect(v_local, halfSize, radius);
      float kind = v_meta.x;
    
      vec2 screen = gl_FragCoord.xy / u_resolution;
      float fade = 1.0;
      if (u_edgeFade.x > 0.0) fade *= smoothstep(0.0, u_edgeFade.x, screen.x) * smoothstep(0.0, u_edgeFade.x, 1.0 - screen.x);
      if (u_edgeFade.y > 0.0) fade *= smoothstep(0.0, u_edgeFade.y, screen.y) * smoothstep(0.0, u_edgeFade.y, 1.0 - screen.y);
    
      if (kind > 0.5 && kind < 1.5) {
        float blur = max(u_shadowBlur, 1.0);
        float a = 1.0 - smoothstep(-blur, blur, d);
        a *= a * v_extra.w * u_shadowOpacity * v_look.x * fade;
        fragColor = vec4(0.0, 0.0, 0.0, a);
        return;
      }
    
      float aa = max(fwidth(d), 1e-3);
      float coverage = 1.0 - smoothstep(-aa * 0.5, aa * 0.5, d);
      if (coverage <= 0.0) discard;
    
      float border = v_extra.z;
      vec2 inner = max(halfSize - border, vec2(1.0));
      bool paper = border > 0.0 && (abs(v_local.x) > inner.x || abs(v_local.y) > inner.y);
      vec3 color = u_placeholder;
      float texAlpha = 1.0;
      if (v_extra.x >= 0.0 && !paper) {
        vec2 uv = border > 0.0 ? v_local / (inner * 2.0) + 0.5 : v_uv;
        // Layers are premultiplied; transparent images (overlays, cut-outs) keep their alpha.
        vec4 texel = texture(u_images, vec3(mix(v_uvRect.xy, v_uvRect.zw, uv), v_extra.x));
        vec3 tex = texel.a > 0.001 ? texel.rgb / texel.a : vec3(0.0);
        color = mix(u_placeholder, tex, v_extra.y);
        texAlpha = mix(1.0, texel.a, v_extra.y);
      }
      if (paper) color = u_paper;
    
      float tone = v_look.z;
      if (tone < 1.0 && !paper) {
        float gray = dot(color, vec3(0.299, 0.587, 0.114));
        if (border > 0.0) {
          // A print develops out of its paper: blank, then faint monochrome, then full color.
          vec3 mono = mix(u_paper, vec3(gray), smoothstep(0.0, 0.6, tone));
          color = mix(mono, color, smoothstep(0.45, 1.0, tone));
        } else {
          color = mix(vec3(mix(0.55, gray, 0.5)), color, tone);
        }
      }
      color = mix(color, v_fill.rgb, v_fill.a);
      color *= 1.0 - v_look.y;
      if (v_look.w > 0.0) color = mix(color, u_ring, smoothstep(-2.0 - aa, -2.0, d) * v_look.w);
    
      float alpha = coverage * v_look.x * fade * texAlpha;
      if (kind > 1.5) {
        float below = u_floorY - v_worldY;
        alpha *= below < 0.0 ? 0.0 : v_meta.y * (1.0 - smoothstep(0.0, u_reflectFade, below));
      }
      fragColor = vec4(color * alpha, alpha);
    }`;
    
    function compile(gl: WebGL2RenderingContext, type: number, source: string) {
      const shader = gl.createShader(type);
      if (!shader) return null;
      gl.shaderSource(shader, source);
      gl.compileShader(shader);
      if (gl.getShaderParameter(shader, gl.COMPILE_STATUS)) return shader;
      if (process.env.NODE_ENV !== "production") console.error(`GalleryRenderer: shader failed to compile\n${gl.getShaderInfoLog(shader)}`);
      gl.deleteShader(shader);
      return null;
    }
    
    function createProgram(gl: WebGL2RenderingContext) {
      const vertex = compile(gl, gl.VERTEX_SHADER, VERTEX);
      const fragment = compile(gl, gl.FRAGMENT_SHADER, FRAGMENT);
      const program = vertex && fragment ? gl.createProgram() : null;
      if (program && vertex && fragment) {
        gl.attachShader(program, vertex);
        gl.attachShader(program, fragment);
        gl.linkProgram(program);
      }
      if (vertex) gl.deleteShader(vertex);
      if (fragment) gl.deleteShader(fragment);
      if (program && gl.getProgramParameter(program, gl.LINK_STATUS)) return program;
      if (program) gl.deleteProgram(program);
      return null;
    }
    
    interface TextureEntry {
      src: string;
      state: "loading" | "decoded" | "ready" | "error";
      image: HTMLImageElement | null;
      layer: number;
      aspect: number;
      /** Content rect inside the layer (images are fitted, not cropped): u0, v0, u1, v1. */
      content: [number, number, number, number];
      loadedAt: number;
      lastUsed: number;
    }
    
    /**
     * WebGL2 renderer for textured cards. Every card, its soft shadow and its floor reflection go
     * into one instanced draw over a texture array, sorted back to front for correct blending.
     * Images load once (crossOrigin "anonymous"), are fitted into fixed-size mipmapped layers and
     * recycled least-recently-used when there are more images than layers.
     */
    export class GalleryRenderer {
      readonly gl: WebGL2RenderingContext;
      readonly software: boolean;
      private program: WebGLProgram;
      private vao: WebGLVertexArrayObject;
      private quad: WebGLBuffer;
      private instances: WebGLBuffer;
      private texture: WebGLTexture;
      private uniforms: Record<string, WebGLUniformLocation | null> = {};
      private data = new Float32Array(FLOATS * 64);
      private capacity = 64;
      private entries = new Map<string, TextureEntry>();
      private freeLayers: number[] = [];
      private scratch: HTMLCanvasElement;
      private mipsDirty = false;
      private frame = 0;
      private images: string[] = [];
      private view = createMat4();
      private projection = createMat4();
      private viewProj = createMat4();
      private inverseViewProj = createMat4();
      private picks: { id: number; model: Mat4; width: number; height: number }[] = [];
      private models: Mat4[] = [];
      private cssWidth = 1;
      private cssHeight = 1;
      /** Called when an image finishes loading, so the owner can draw a new frame. */
      onImageLoad: () => void = () => {};
    
      private constructor(
        gl: WebGL2RenderingContext,
        readonly textureSize: number,
        readonly layers: number,
        software: boolean,
      ) {
        this.gl = gl;
        this.software = software;
        const program = createProgram(gl);
        const vao = gl.createVertexArray();
        const quad = gl.createBuffer();
        const instances = gl.createBuffer();
        const texture = gl.createTexture();
        if (!program || !vao || !quad || !instances || !texture) throw new Error("GalleryRenderer: WebGL setup failed");
        this.program = program;
        this.vao = vao;
        this.quad = quad;
        this.instances = instances;
        this.texture = texture;
        this.scratch = document.createElement("canvas");
        this.scratch.width = this.scratch.height = textureSize;
        this.setup();
      }
    
      /** Creates a renderer on `canvas`, or returns null when WebGL2 or the shaders are unavailable. */
      static create(canvas: HTMLCanvasElement, { textureSize = 512, layers = 32 }: { textureSize?: number; layers?: number } = {}) {
        const gl = canvas.getContext("webgl2", { alpha: true, antialias: false, depth: false, stencil: false, premultipliedAlpha: true });
        if (!gl) return null;
        const info = gl.getExtension("WEBGL_debug_renderer_info");
        const name = info ? String(gl.getParameter(info.UNMASKED_RENDERER_WEBGL)) : String(gl.getParameter(gl.RENDERER));
        const software = /swiftshader|llvmpipe|softpipe|software|basic render/i.test(name);
        const maxLayers = gl.getParameter(gl.MAX_ARRAY_TEXTURE_LAYERS) as number;
        try {
          return new GalleryRenderer(gl, textureSize, clamp(layers, 1, maxLayers), software);
        } catch {
          return null;
        }
      }
    
      private setup() {
        const { gl } = this;
        for (const name of ["u_viewProj", "u_floorY", "u_shadowOffset", "u_shadowBlur", "u_shadowOpacity", "u_images", "u_resolution", "u_edgeFade", "u_placeholder", "u_paper", "u_ring", "u_reflectFade"]) {
          this.uniforms[name] = gl.getUniformLocation(this.program, name);
        }
        gl.bindVertexArray(this.vao);
        gl.bindBuffer(gl.ARRAY_BUFFER, this.quad);
        gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-0.5, -0.5, 0.5, -0.5, 0.5, 0.5, -0.5, -0.5, 0.5, 0.5, -0.5, 0.5]), gl.STATIC_DRAW);
        gl.enableVertexAttribArray(0);
        gl.vertexAttribPointer(0, 2, gl.FLOAT, false, 0, 0);
        gl.bindBuffer(gl.ARRAY_BUFFER, this.instances);
        gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
        const stride = FLOATS * 4;
        // mat4 model (locations 1–4), then six vec4s (5–10).
        for (let i = 0; i < 10; i++) {
          const location = i + 1;
          gl.enableVertexAttribArray(location);
          gl.vertexAttribPointer(location, 4, gl.FLOAT, false, stride, i * 16);
          gl.vertexAttribDivisor(location, 1);
        }
        gl.bindVertexArray(null);
    
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
        const levels = Math.floor(Math.log2(this.textureSize)) + 1;
        gl.texStorage3D(gl.TEXTURE_2D_ARRAY, levels, gl.RGBA8, this.textureSize, this.textureSize, this.layers);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MIN_FILTER, gl.LINEAR_MIPMAP_LINEAR);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
        gl.texParameteri(gl.TEXTURE_2D_ARRAY, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
        const anisotropic = gl.getExtension("EXT_texture_filter_anisotropic");
        if (anisotropic) gl.texParameterf(gl.TEXTURE_2D_ARRAY, anisotropic.TEXTURE_MAX_ANISOTROPY_EXT, Math.min(4, gl.getParameter(anisotropic.MAX_TEXTURE_MAX_ANISOTROPY_EXT) as number));
        this.freeLayers = Array.from({ length: this.layers }, (_, i) => i);
        this.entries.forEach((entry) => {
          entry.layer = -1;
          if (entry.state === "ready") entry.state = "decoded";
        });
      }
    
      /** Rebuilds GPU resources after a lost WebGL context is restored. Images re-upload from memory. */
      restore() {
        const { gl } = this;
        const vao = gl.createVertexArray();
        const quad = gl.createBuffer();
        const instances = gl.createBuffer();
        const texture = gl.createTexture();
        if (!vao || !quad || !instances || !texture) return false;
        this.vao = vao;
        this.quad = quad;
        this.instances = instances;
        this.texture = texture;
        const program = createProgram(gl);
        if (!program) return false;
        this.program = program;
        this.capacity = this.data.length / FLOATS;
        this.setup();
        return true;
      }
    
      /** Image URLs that cards refer to by index. Unchanged URLs keep their textures. */
      setImages(images: string[]) {
        this.images = images;
      }
    
      resize(cssWidth: number, cssHeight: number, dpr: number) {
        const canvas = this.gl.canvas as HTMLCanvasElement;
        const width = Math.max(1, Math.round(cssWidth * dpr));
        const height = Math.max(1, Math.round(cssHeight * dpr));
        if (canvas.width !== width || canvas.height !== height) {
          canvas.width = width;
          canvas.height = height;
        }
        this.cssWidth = Math.max(1, cssWidth);
        this.cssHeight = Math.max(1, cssHeight);
      }
    
      private request(index: number) {
        const src = this.images[index];
        if (!src) return null;
        let entry = this.entries.get(src);
        if (!entry) {
          const created: TextureEntry = { src, state: "loading", image: null, layer: -1, aspect: 1, content: [0, 0, 1, 1], loadedAt: 0, lastUsed: 0 };
          entry = created;
          this.entries.set(src, created);
          const image = new Image();
          image.crossOrigin = "anonymous";
          image.decoding = "async";
          image.src = src;
          image.decode().then(
            () => {
              created.image = image;
              created.aspect = image.naturalWidth / Math.max(1, image.naturalHeight);
              created.state = "decoded";
              this.onImageLoad();
            },
            () => {
              created.state = "error";
            },
          );
        }
        entry.lastUsed = this.frame;
        return entry;
      }
    
      private upload(entry: TextureEntry) {
        if (!entry.image) return;
        let layer = this.freeLayers.pop();
        if (layer === undefined) {
          // Recycle the least recently used layer that no card showed this frame.
          let oldest: TextureEntry | null = null;
          this.entries.forEach((candidate) => {
            if (candidate.layer >= 0 && candidate.lastUsed < this.frame && (!oldest || candidate.lastUsed < oldest.lastUsed)) oldest = candidate;
          });
          const victim = oldest as TextureEntry | null;
          if (!victim) return;
          layer = victim.layer;
          victim.layer = -1;
          victim.state = "decoded";
        }
        const size = this.textureSize;
        const ctx = this.scratch.getContext("2d");
        if (!ctx) return;
        const { naturalWidth: w, naturalHeight: h } = entry.image;
        const fit = Math.min(size / w, size / h);
        const dw = w * fit;
        const dh = h * fit;
        ctx.clearRect(0, 0, size, size);
        ctx.drawImage(entry.image, (size - dw) / 2, (size - dh) / 2, dw, dh);
        const { gl } = this;
        gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
        gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true);
        gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
        gl.texSubImage3D(gl.TEXTURE_2D_ARRAY, 0, 0, 0, layer, size, size, 1, gl.RGBA, gl.UNSIGNED_BYTE, this.scratch);
        const u = dw / size / 2;
        const v = dh / size / 2;
        entry.content = [0.5 - u, 0.5 - v, 0.5 + u, 0.5 + v];
        entry.layer = layer;
        entry.state = "ready";
        if (!entry.loadedAt) entry.loadedAt = performance.now();
        this.mipsDirty = true;
      }
    
      /** Cover-fits a card onto its image's content rect, optionally panned. */
      private coverUv(entry: TextureEntry, width: number, height: number, panX: number, panY: number, out: Float32Array, offset: number) {
        const cardAspect = width / Math.max(1, height);
        let uSpan = 1;
        let vSpan = 1;
        if (cardAspect > entry.aspect) vSpan = entry.aspect / cardAspect;
        else uSpan = cardAspect / entry.aspect;
        const uc = 0.5 + (clamp(panX, -1, 1) * (1 - uSpan)) / 2;
        const vc = 0.5 + (clamp(panY, -1, 1) * (1 - vSpan)) / 2;
        const [u0, v0, u1, v1] = entry.content;
        out[offset] = u0 + (uc - uSpan / 2) * (u1 - u0);
        out[offset + 1] = v0 + (vc - vSpan / 2) * (v1 - v0);
        out[offset + 2] = u0 + (uc + uSpan / 2) * (u1 - u0);
        out[offset + 3] = v0 + (vc + vSpan / 2) * (v1 - v0);
      }
    
      private updateCamera(scene: GalleryScene) {
        const fov = ((scene.fov ?? 30) * Math.PI) / 180;
        const distance = this.cssHeight / 2 / Math.tan(fov / 2);
        const c = scene.camera ?? {};
        const eye = [c.x ?? 0, c.y ?? 0, distance + (c.z ?? 0)];
        lookAt(this.view, eye, [c.targetX ?? 0, c.targetY ?? 0, c.targetZ ?? 0]);
        perspective(this.projection, fov, this.cssWidth / this.cssHeight, Math.max(1, distance * 0.02), distance * 20);
        multiply(this.viewProj, this.projection, this.view);
        invert(this.inverseViewProj, this.viewProj);
      }
    
      /** Draws one frame. Returns true while any image is still fading in. */
      render(cards: readonly GalleryCard[], scene: GalleryScene = {}) {
        const { gl } = this;
        this.frame++;
        this.updateCamera(scene);
        const now = performance.now();
        let fading = false;
    
        // Resolve textures and upload anything that finished decoding.
        const textures: (TextureEntry | null)[] = cards.map((card) => (card.image >= 0 ? this.request(card.image) : null));
        for (const entry of textures) if (entry && entry.state === "decoded") this.upload(entry);
        if (this.mipsDirty) {
          gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
          gl.generateMipmap(gl.TEXTURE_2D_ARRAY);
          this.mipsDirty = false;
        }
    
        // Back-to-front order by view-space depth.
        while (this.models.length < cards.length) this.models.push(createMat4());
        const order = cards.map((card, i) => {
          compose(this.models[i], card.x, card.y, card.z, card.rotateX ?? 0, card.rotateY ?? 0, card.rotateZ ?? 0, card.scale ?? 1, card.flip ?? 0);
          const depth = this.view[2] * card.x + this.view[6] * card.y + this.view[10] * card.z + this.view[14];
          return { i, depth };
        });
        order.sort((a, b) => a.depth - b.depth);
    
        const reflections = scene.floorY !== undefined ? order.filter(({ i }) => (cards[i].reflection ?? 0) > 0) : [];
        const needed = reflections.length + order.length * 2;
        if (needed > this.capacity) {
          this.capacity = Math.max(needed, this.capacity * 2);
          this.data = new Float32Array(this.capacity * FLOATS);
          gl.bindBuffer(gl.ARRAY_BUFFER, this.instances);
          gl.bufferData(gl.ARRAY_BUFFER, this.data.byteLength, gl.DYNAMIC_DRAW);
        }
    
        let count = 0;
        const write = (i: number, kind: number) => {
          const card = cards[i];
          const entry = textures[i];
          const o = count * FLOATS;
          const d = this.data;
          d.set(this.models[i], o);
          d[o + 16] = card.width;
          d[o + 17] = card.height;
          d[o + 18] = card.radius ?? 12;
          d[o + 19] = card.round ?? 0;
          d[o + 20] = card.opacity ?? 1;
          d[o + 21] = card.dim ?? 0;
          d[o + 22] = card.tone ?? 1;
          d[o + 23] = card.ring ?? 0;
          const ready = entry !== null && entry.state === "ready" && entry.layer >= 0;
          const loaded = ready ? clamp((now - entry.loadedAt) / 320, 0, 1) : 0;
          if (ready && loaded < 1) fading = true;
          d[o + 24] = ready ? entry.layer : -1;
          d[o + 25] = loaded;
          d[o + 26] = card.border ?? 0;
          d[o + 27] = card.shadow ?? 0;
          d[o + 28] = kind;
          d[o + 29] = card.reflection ?? 0;
          d[o + 30] = 0;
          d[o + 31] = 0;
          if (ready) this.coverUv(entry, card.width - (card.border ?? 0) * 2, card.height - (card.border ?? 0) * 2, card.panX ?? 0, card.panY ?? 0, d, o + 32);
          else d.fill(0, o + 32, o + 36);
          const fill = card.fill;
          d[o + 36] = fill ? fill[0] : 0;
          d[o + 37] = fill ? fill[1] : 0;
          d[o + 38] = fill ? fill[2] : 0;
          d[o + 39] = fill ? fill[3] : 0;
          count++;
        };
        for (const { i } of reflections) write(i, KIND_REFLECTION);
        this.picks.length = 0;
        for (const { i } of order) {
          const card = cards[i];
          if ((card.shadow ?? 0) > 0 && (card.opacity ?? 1) > 0) write(i, KIND_SHADOW);
          write(i, KIND_CARD);
          this.picks.push({ id: card.id ?? i, model: this.models[i], width: card.width, height: card.height });
        }
    
        const canvas = gl.canvas as HTMLCanvasElement;
        gl.viewport(0, 0, canvas.width, canvas.height);
        gl.clearColor(0, 0, 0, 0);
        gl.clear(gl.COLOR_BUFFER_BIT);
        if (count) {
          gl.enable(gl.BLEND);
          gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
          gl.useProgram(this.program);
          const u = this.uniforms;
          gl.uniformMatrix4fv(u.u_viewProj, false, this.viewProj);
          gl.uniform1f(u.u_floorY, scene.floorY ?? -1e6);
          gl.uniform1f(u.u_reflectFade, scene.reflectionFade ?? 160);
          gl.uniform1f(u.u_shadowOffset, scene.shadowOffset ?? 14);
          gl.uniform1f(u.u_shadowBlur, scene.shadowBlur ?? 22);
          gl.uniform1f(u.u_shadowOpacity, scene.shadowOpacity ?? 0.28);
          gl.uniform2f(u.u_resolution, canvas.width, canvas.height);
          gl.uniform2f(u.u_edgeFade, scene.edgeFade?.[0] ?? 0, scene.edgeFade?.[1] ?? 0);
          gl.uniform3fv(u.u_placeholder, scene.placeholder ?? [0.94, 0.94, 0.95]);
          gl.uniform3fv(u.u_paper, scene.paper ?? [0.98, 0.97, 0.95]);
          gl.uniform3fv(u.u_ring, scene.ring ?? [0.145, 0.388, 0.922]);
          gl.uniform1i(u.u_images, 0);
          gl.activeTexture(gl.TEXTURE0);
          gl.bindTexture(gl.TEXTURE_2D_ARRAY, this.texture);
          gl.bindBuffer(gl.ARRAY_BUFFER, this.instances);
          gl.bufferSubData(gl.ARRAY_BUFFER, 0, this.data, 0, count * FLOATS);
          gl.bindVertexArray(this.vao);
          gl.drawArraysInstanced(gl.TRIANGLES, 0, 6, count);
          gl.bindVertexArray(null);
        }
        return fading;
      }
    
      /** The front-most card under a point in CSS px (relative to the canvas), or null. */
      pick(x: number, y: number): PickResult | null {
        const nx = (x / this.cssWidth) * 2 - 1;
        const ny = 1 - (y / this.cssHeight) * 2;
        const near = transform(this.inverseViewProj, nx, ny, -1);
        const far = transform(this.inverseViewProj, nx, ny, 1);
        const origin = [near[0] / near[3], near[1] / near[3], near[2] / near[3]];
        const end = [far[0] / far[3], far[1] / far[3], far[2] / far[3]];
        const inverse = createMat4();
        for (let k = this.picks.length - 1; k >= 0; k--) {
          const { id, model, width, height } = this.picks[k];
          if (!invert(inverse, model)) continue;
          const o = transform(inverse, origin[0], origin[1], origin[2]);
          const e = transform(inverse, end[0], end[1], end[2]);
          const dz = e[2] - o[2];
          if (Math.abs(dz) < 1e-9) continue;
          const t = -o[2] / dz;
          if (t < 0 || t > 1) continue;
          const px = o[0] + (e[0] - o[0]) * t;
          const py = o[1] + (e[1] - o[1]) * t;
          if (Math.abs(px) <= width / 2 && Math.abs(py) <= height / 2) return { id, u: px / width + 0.5, v: py / height + 0.5 };
        }
        return null;
      }
    
      /** World point → CSS px relative to the canvas (for positioning DOM overlays). */
      project(x: number, y: number, z: number) {
        const [cx, cy, , cw] = transform(this.viewProj, x, y, z);
        return { x: ((cx / cw) * 0.5 + 0.5) * this.cssWidth, y: (0.5 - (cy / cw) * 0.5) * this.cssHeight, visible: cw > 0 };
      }
    
      /** True once the image at `index` is on the GPU. */
      isLoaded(index: number) {
        const src = this.images[index];
        return src ? this.entries.get(src)?.state === "ready" : false;
      }
    
      destroy() {
        const { gl } = this;
        gl.deleteBuffer(this.quad);
        gl.deleteBuffer(this.instances);
        gl.deleteVertexArray(this.vao);
        gl.deleteTexture(this.texture);
        gl.deleteProgram(this.program);
        this.entries.clear();
        gl.getExtension("WEBGL_lose_context")?.loseContext();
      }
    }
    lib/gallery/types.ts
    import type { CSSProperties, Ref } from "react";
    
    /** One image in a gallery. */
    export interface GalleryItem {
      src: string;
      /** Describes the image for screen readers and the fallback list. */
      alt: string;
      title?: string;
      subtitle?: string;
      /** Optional link, e.g. opened by onSelect. */
      href?: string;
    }
    
    /** Imperative handle shared by every gallery. */
    export interface GalleryHandle {
      next: () => void;
      previous: () => void;
      goTo: (index: number) => void;
    }
    
    /**
     * Props every gallery accepts. Each gallery copies these into its own props interface (with the
     * JSDoc and defaults that fit it) so its props table stays complete.
     */
    export interface GallerySharedProps {
      items: GalleryItem[];
      startIndex?: number;
      autoplay?: boolean;
      interval?: number;
      pauseOnHover?: boolean;
      draggable?: boolean;
      wheel?: boolean;
      captions?: boolean;
      controls?: boolean;
      backgroundColor?: string;
      paused?: boolean;
      dpr?: number;
      ariaLabel?: string;
      className?: string;
      style?: CSSProperties;
      onIndexChange?: (index: number) => void;
      onSelect?: (item: GalleryItem, index: number) => void;
      ref?: Ref<GalleryHandle>;
    }
    lib/gallery/use-gallery-input.ts
    import { useEffect, useMemo, useRef, useState } from "react";
    import { clamp, wrap } from "@/lib/gallery/math";
    
    export interface DragMove {
      /** Pointer movement since the previous move event, CSS px. */
      dx: number;
      dy: number;
      /** Pointer and drag-start positions relative to the element. */
      x: number;
      y: number;
      startX: number;
      startY: number;
      width: number;
      height: number;
    }
    
    export interface UseGalleryInputOptions {
      /** Number of items. */
      count: number;
      /** Wrap past the first and last item. @default false */
      loop?: boolean;
      /** Index shown first. @default 0 */
      startIndex?: number;
      /** Pointer drag with momentum. @default true */
      draggable?: boolean;
      /** One item per wheel notch. At the first or last item the page scrolls instead. @default true */
      wheel?: boolean;
      /** ← → (or ↑ ↓ on the y axis), Home, End, Enter, Escape and Space. @default true */
      keyboard?: boolean;
      /** Settle on the nearest item after a drag or throw. @default true */
      snap?: boolean;
      /** Seconds of glide a throw carries; 0 stops dead. @default 0.35 */
      inertia?: number;
      /** Drag and arrow-key axis. @default "x" */
      axis?: "x" | "y";
      /** Pixels of drag that move one item, or a function of the element's size. @default 200 */
      dragDistance?: number | ((width: number, height: number) => number);
      /** Custom drag mapping (e.g. turning a wheel around its center): returns the change in position, in items. */
      dragDelta?: (move: DragMove) => number;
      /** Step to the next item every `interval`. @default false */
      autoplay?: boolean;
      /** Autoplay interval in ms. @default 4000 */
      interval?: number;
      /** Pause autoplay while hovered. @default true */
      pauseOnHover?: boolean;
      /** Stops autoplay (input still works). @default false */
      paused?: boolean;
      /** Space toggles pause even without step autoplay, for galleries with continuous motion (drift, spin, streams). @default false */
      pausable?: boolean;
      /** Request a frame on every pointer move, e.g. for hover picking. @default false */
      trackPointer?: boolean;
      onIndexChange?: (index: number) => void;
      /** Enter on the gallery itself (not on a button inside it). */
      onSelect?: (index: number) => void;
      onEscape?: () => void;
      /** A press without a drag, in element coordinates. */
      onTap?: (point: { x: number; y: number }, input: Omit<GalleryInput, "index" | "autoplaying" | "userPaused">) => void;
      /** Called whenever the position changes so a renderer can draw. */
      invalidate?: () => void;
    }
    
    export interface GalleryInput {
      /** Pass as the gallery root's `ref`: the root receives pointer, wheel and key events. */
      bind: (element: HTMLElement | null) => void | (() => void);
      /** Active item (nearest to the current position). */
      index: number;
      /** Autoplay is on and not paused by the user or the `paused` prop. */
      autoplaying: boolean;
      /** The user paused motion (Space or `togglePause`). */
      userPaused: boolean;
      /** Current position in items (fractional while moving; unbounded when looping). */
      position: () => number;
      /** Active item right now (use in event handlers; `index` is the rendered value). */
      activeIndex: () => number;
      /** Velocity in items per second. */
      velocity: () => number;
      /** Advances the physics by dt seconds. Returns true while still moving. */
      update: (dt: number) => boolean;
      /** Moves the position by `delta` items with no spring (continuous drift). Ignored while a gesture or snap is in progress. */
      drift: (delta: number) => void;
      /** Drags from another control (e.g. a ruler): moves by `delta` items like a pointer drag. Finish with `scrubEnd()`. */
      scrub: (delta: number) => void;
      /** Ends a scrub: momentum, snapping and the quick-flick rule apply as for a drag. */
      scrubEnd: () => void;
      next: () => void;
      previous: () => void;
      goTo: (index: number, options?: { instant?: boolean }) => void;
      dragging: () => boolean;
      hovering: () => boolean;
      /** Last pointer position over the element. */
      pointer: () => { x: number; y: number; inside: boolean };
      /** True when autoplay or drift may run right now (not hovered, dragged, held, hidden or reduced). */
      canAutoplay: () => boolean;
      /** Holds autoplay for a reason, e.g. while an item is open. */
      hold: (reason: string, active: boolean) => void;
      togglePause: () => void;
    }
    
    const reducedMotion = () => typeof window !== "undefined" && window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    
    /**
     * One input model for every gallery: a continuous position (in items) driven by pointer drag with
     * momentum and snapping, wheel notches, the keyboard and autoplay. Physics runs in `update(dt)`,
     * which the gallery's render loop calls each frame, so nothing re-renders React mid-gesture.
     */
    export function useGalleryInput(options: UseGalleryInputOptions): GalleryInput {
      const latest = useRef(options);
      const startIndex = clamp(Math.round(options.startIndex ?? 0), 0, Math.max(0, options.count - 1));
      const [index, setIndex] = useState(startIndex);
      const [userPaused, setUserPaused] = useState(false);
      const restartAutoplay = useRef(() => {});
      const state = useRef({
        p: startIndex,
        raw: startIndex,
        v: 0,
        target: null as number | null,
        dragging: false,
        pending: false,
        pointerId: -1,
        startX: 0,
        startY: 0,
        startP: 0,
        downT: 0,
        lastX: 0,
        lastY: 0,
        samples: [] as { t: number; p: number }[],
        hovering: false,
        inside: false,
        px: 0,
        py: 0,
        wheelAcc: 0,
        wheelAt: 0,
        lastIndex: startIndex,
        suppressClick: false,
        holds: new Set<string>(),
        userPaused: false,
      });
    
      useEffect(() => {
        latest.current = options;
      });
    
      const api = useMemo(() => {
        const st = () => state.current;
        const max = () => Math.max(0, latest.current.count - 1);
        const kick = () => latest.current.invalidate?.();
    
        const sync = () => {
          const s = st();
          const { count, loop } = latest.current;
          if (count <= 0) return;
          const i = loop ? wrap(Math.round(s.p), count) : clamp(Math.round(s.p), 0, count - 1);
          if (i === s.lastIndex) return;
          s.lastIndex = i;
          setIndex(i);
          latest.current.onIndexChange?.(i);
        };
    
        const moveTo = (position: number, instant = false) => {
          const s = st();
          s.dragging = false;
          s.target = position;
          if (instant || reducedMotion()) {
            s.p = s.raw = position;
            s.v = 0;
            s.target = null;
          }
          sync();
          kick();
          restartAutoplay.current();
        };
    
        const goTo = (target: number, opts?: { instant?: boolean }) => {
          const { count, loop } = latest.current;
          if (!count) return;
          const s = st();
          const base = Math.round(s.target ?? s.p);
          if (loop) {
            let delta = wrap(Math.round(target) - wrap(base, count), count);
            if (delta > count / 2) delta -= count;
            moveTo(base + delta, opts?.instant);
          } else {
            moveTo(clamp(Math.round(target), 0, max()), opts?.instant);
          }
        };
    
        const step = (direction: number, cycle = false) => {
          const s = st();
          const base = Math.round(s.target ?? s.p);
          if (latest.current.loop) return moveTo(base + direction);
          const next = base + direction;
          if (cycle && next > max()) return moveTo(0);
          moveTo(clamp(next, 0, max()));
        };
    
        const canAutoplay = () => {
          const o = latest.current;
          const s = st();
          return (
            o.count > 1 &&
            !o.paused &&
            !s.userPaused &&
            !reducedMotion() &&
            !((o.pauseOnHover ?? true) && s.hovering) &&
            !s.dragging &&
            s.holds.size === 0 &&
            document.visibilityState === "visible"
          );
        };
    
        const update = (dt: number) => {
          const s = st();
          if (s.dragging) return true;
          if (s.target !== null) {
            if (reducedMotion()) {
              s.p = s.raw = s.target;
              s.v = 0;
              s.target = null;
              sync();
              return false;
            }
            // Critically damped spring, sub-stepped for stability on slow frames.
            const omega = 11;
            let remaining = dt;
            while (remaining > 0) {
              const h = Math.min(remaining, 1 / 120);
              s.v += (-omega * omega * (s.p - s.target) - 2 * omega * s.v) * h;
              s.p += s.v * h;
              remaining -= h;
            }
            if (Math.abs(s.p - s.target) < 0.0008 && Math.abs(s.v) < 0.01) {
              s.p = s.target;
              s.v = 0;
              s.target = null;
            }
            s.raw = s.p;
            sync();
            return s.target !== null;
          }
          if (s.v !== 0) {
            s.v *= Math.exp(-dt / Math.max(0.05, latest.current.inertia ?? 0.35));
            s.p += s.v * dt;
            if (!latest.current.loop && (s.p < 0 || s.p > max())) s.target = clamp(s.p, 0, max());
            if (Math.abs(s.v) < 0.02) s.v = 0;
            s.raw = s.p;
            sync();
            return s.v !== 0 || s.target !== null;
          }
          return false;
        };
    
        const release = () => {
          const s = st();
          s.dragging = false;
          const now = performance.now();
          const samples = s.samples;
          const lastSample = samples[samples.length - 1];
          let v = 0;
          // Only a pointer still moving at release throws. Use the last 150ms, but always at least the
          // last two samples: slow devices and coalesced events can space moves 80ms or more apart.
          if (lastSample && now - lastSample.t < 100) {
            let first = samples.find((sample) => now - sample.t < 150) ?? lastSample;
            if (first === lastSample && samples.length >= 2) first = samples[samples.length - 2];
            if (first !== lastSample) v = (lastSample.p - first.p) / Math.max(0.016, (lastSample.t - first.t) / 1000);
          }
          v = clamp(v, -30, 30);
          const { snap = true, inertia = 0.35, loop } = latest.current;
          if (snap) {
            let target = Math.round(s.p + v * inertia);
            const from = Math.round(s.startP);
            // A quick flick always moves at least one item: either it was still moving fast at release,
            // or the whole press was short and travelled a noticeable distance.
            const travel = s.p - s.startP;
            const quick = now - s.downT < 320 && Math.abs(travel) > 0.06;
            if (target === from && (Math.abs(v) > 0.6 || quick)) target = from + Math.sign(Math.abs(v) > 0.6 ? v : travel);
            s.target = loop ? target : clamp(target, 0, max());
          } else if (!loop && (s.p < 0 || s.p > max())) {
            s.target = clamp(s.p, 0, max());
          }
          s.v = v;
          if (reducedMotion()) {
            s.p = s.raw = s.target ?? s.p;
            s.v = 0;
            s.target = null;
          }
          sync();
          kick();
          restartAutoplay.current();
        };
    
        const togglePause = () => {
          const s = st();
          s.userPaused = !s.userPaused;
          setUserPaused(s.userPaused);
          restartAutoplay.current();
        };
    
        const bind = (element: HTMLElement | null) => {
          if (!element) return;
          const s = st();
          const local = (event: { clientX: number; clientY: number }) => {
            const rect = element.getBoundingClientRect();
            return { x: event.clientX - rect.left, y: event.clientY - rect.top, width: rect.width, height: rect.height };
          };
          const onDown = (event: PointerEvent) => {
            if (event.pointerType === "mouse" && event.button !== 0) return;
            if ((event.target as Element | null)?.closest?.("[data-gallery-control]")) return;
            const point = local(event);
            s.pending = true;
            s.pointerId = event.pointerId;
            s.startX = s.lastX = point.x;
            s.startY = s.lastY = point.y;
            s.downT = performance.now();
          };
          const onMove = (event: PointerEvent) => {
            const point = local(event);
            s.px = point.x;
            s.py = point.y;
            s.inside = true;
            if (latest.current.trackPointer) kick();
            if (!s.pending || event.pointerId !== s.pointerId) return;
            if (!s.dragging) {
              if (!(latest.current.draggable ?? true)) return;
              if (Math.hypot(point.x - s.startX, point.y - s.startY) < 6) return;
              // Capture only once the pointer really moves, so clicks on links and buttons still land.
              s.dragging = true;
              s.target = null;
              s.v = 0;
              s.startP = s.raw = s.p;
              // Measure from the press: a fast flick may arrive as a single coalesced move event, and its
              // full distance (and speed) must still count.
              s.samples = [{ t: s.downT, p: s.p }];
              s.lastX = s.startX;
              s.lastY = s.startY;
              try {
                element.setPointerCapture(event.pointerId);
              } catch {}
            }
            const move: DragMove = { dx: point.x - s.lastX, dy: point.y - s.lastY, x: point.x, y: point.y, startX: s.startX, startY: s.startY, width: point.width, height: point.height };
            const o = latest.current;
            let delta: number;
            if (o.dragDelta) delta = o.dragDelta(move);
            else {
              const distance = typeof o.dragDistance === "function" ? o.dragDistance(point.width, point.height) : (o.dragDistance ?? 200);
              delta = -((o.axis ?? "x") === "y" ? move.dy : move.dx) / Math.max(1, distance);
            }
            s.raw += delta;
            s.p = rubber(s.raw);
            s.lastX = point.x;
            s.lastY = point.y;
            s.samples.push({ t: performance.now(), p: s.p });
            if (s.samples.length > 12) s.samples.shift();
            sync();
            kick();
          };
          const onUp = (event: PointerEvent) => {
            if (event.pointerId !== s.pointerId) return;
            const wasDragging = s.dragging;
            s.pending = false;
            s.pointerId = -1;
            if (wasDragging) {
              s.suppressClick = true;
              window.setTimeout(() => {
                s.suppressClick = false;
              }, 0);
              try {
                element.releasePointerCapture(event.pointerId);
              } catch {}
              release();
            } else if (event.type === "pointerup") {
              latest.current.onTap?.(local(event), controls);
            }
          };
          const onClickCapture = (event: MouseEvent) => {
            if (!s.suppressClick) return;
            event.preventDefault();
            event.stopPropagation();
          };
          const onEnter = (event: PointerEvent) => {
            if (event.pointerType !== "mouse") return;
            s.hovering = true;
            kick();
          };
          const onLeave = () => {
            s.hovering = false;
            s.inside = false;
            kick();
            restartAutoplay.current();
          };
          const onWheel = (event: WheelEvent) => {
            if (!(latest.current.wheel ?? true)) return;
            const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? element.clientHeight : 1;
            const dx = event.deltaX * scale;
            const dy = event.deltaY * scale;
            const delta = Math.abs(dx) > Math.abs(dy) ? dx : dy;
            if (!delta) return;
            const direction = Math.sign(delta);
            const base = Math.round(s.target ?? s.p);
            // At either end, let the page scroll instead of trapping the wheel.
            if (!latest.current.loop && ((direction < 0 && base <= 0) || (direction > 0 && base >= max()))) {
              s.wheelAcc = 0;
              return;
            }
            event.preventDefault();
            s.wheelAcc += delta;
            const now = performance.now();
            const notch = event.deltaMode !== 0 || Math.abs(delta) >= 50;
            if ((notch || Math.abs(s.wheelAcc) >= 60) && now - s.wheelAt > 110) {
              s.wheelAt = now;
              s.wheelAcc = 0;
              step(direction);
            }
          };
          const onKeyDown = (event: KeyboardEvent) => {
            const o = latest.current;
            if (!(o.keyboard ?? true)) return;
            const target = event.target as HTMLElement;
            if (target.closest("input, textarea, select, [contenteditable='true']")) return;
            const vertical = (o.axis ?? "x") === "y";
            if (event.key === (vertical ? "ArrowDown" : "ArrowRight")) {
              event.preventDefault();
              step(1);
            } else if (event.key === (vertical ? "ArrowUp" : "ArrowLeft")) {
              event.preventDefault();
              step(-1);
            } else if (event.key === "Home") {
              event.preventDefault();
              goTo(0);
            } else if (event.key === "End") {
              event.preventDefault();
              goTo(max());
            } else if (event.key === "Enter") {
              // Buttons and links inside the gallery handle Enter themselves.
              if (target !== element && target.closest("button, a")) return;
              event.preventDefault();
              o.onSelect?.(s.lastIndex);
            } else if (event.key === "Escape") {
              o.onEscape?.();
            } else if (event.key === " " && (o.autoplay || o.pausable) && !target.closest("[data-gallery-control]")) {
              event.preventDefault();
              togglePause();
            }
          };
          const onKeyUp = (event: KeyboardEvent) => {
            const o = latest.current;
            if (event.key === " " && (o.autoplay || o.pausable) && (o.keyboard ?? true) && !(event.target as HTMLElement).closest("[data-gallery-control]")) event.preventDefault();
          };
          const onDragStart = (event: Event) => event.preventDefault();
    
          element.addEventListener("pointerdown", onDown);
          element.addEventListener("pointermove", onMove);
          element.addEventListener("pointerup", onUp);
          element.addEventListener("pointercancel", onUp);
          element.addEventListener("pointerenter", onEnter);
          element.addEventListener("pointerleave", onLeave);
          element.addEventListener("click", onClickCapture, true);
          element.addEventListener("wheel", onWheel, { passive: false });
          element.addEventListener("keydown", onKeyDown);
          element.addEventListener("keyup", onKeyUp);
          element.addEventListener("dragstart", onDragStart);
          return () => {
            element.removeEventListener("pointerdown", onDown);
            element.removeEventListener("pointermove", onMove);
            element.removeEventListener("pointerup", onUp);
            element.removeEventListener("pointercancel", onUp);
            element.removeEventListener("pointerenter", onEnter);
            element.removeEventListener("pointerleave", onLeave);
            element.removeEventListener("click", onClickCapture, true);
            element.removeEventListener("wheel", onWheel);
            element.removeEventListener("keydown", onKeyDown);
            element.removeEventListener("keyup", onKeyUp);
            element.removeEventListener("dragstart", onDragStart);
          };
        };
    
        const rubber = (value: number) => {
          if (latest.current.loop) return value;
          const limit = max();
          if (value < 0) return value * 0.35;
          if (value > limit) return limit + (value - limit) * 0.35;
          return value;
        };
    
        const scrub = (delta: number) => {
          const s = st();
          const now = performance.now();
          if (!s.dragging) {
            s.dragging = true;
            s.target = null;
            s.v = 0;
            s.startP = s.raw = s.p;
            s.downT = now;
            s.samples = [{ t: now, p: s.p }];
          }
          s.raw += delta;
          s.p = rubber(s.raw);
          s.samples.push({ t: now, p: s.p });
          if (s.samples.length > 12) s.samples.shift();
          sync();
          kick();
        };
    
        const scrubEnd = () => {
          if (st().dragging) release();
        };
    
        const drift = (delta: number) => {
          const s = st();
          if (s.dragging || s.pending || s.target !== null || s.v !== 0 || !delta) return;
          s.p = latest.current.loop ? s.p + delta : clamp(s.p + delta, 0, max());
          s.raw = s.p;
          sync();
        };
    
        const controls: Omit<GalleryInput, "index" | "autoplaying" | "userPaused"> = {
          bind,
          position: () => st().p,
          activeIndex: () => st().lastIndex,
          velocity: () => st().v,
          update,
          drift,
          scrub,
          scrubEnd,
          next: () => step(1),
          previous: () => step(-1),
          goTo,
          dragging: () => st().dragging,
          hovering: () => st().hovering,
          pointer: () => ({ x: st().px, y: st().py, inside: st().inside }),
          canAutoplay,
          hold: (reason: string, active: boolean) => {
            const s = st();
            if (active) s.holds.add(reason);
            else s.holds.delete(reason);
            restartAutoplay.current();
            kick();
          },
          togglePause,
        };
        // Autoplay cycles back to the start at the end of a non-looping gallery.
        return { controls, autoStep: () => step(1, true) };
      }, []);
    
      const { autoplay, interval } = options;
      useEffect(() => {
        if (!autoplay) return;
        const wait = Math.max(800, interval ?? 4000);
        let timer = 0;
        const tick = () => {
          timer = window.setTimeout(() => {
            if (api.controls.canAutoplay()) api.autoStep();
            tick();
          }, wait);
        };
        // Any manual move restarts the countdown so autoplay never jumps right after the user acts.
        restartAutoplay.current = () => {
          window.clearTimeout(timer);
          tick();
        };
        tick();
        return () => {
          window.clearTimeout(timer);
          restartAutoplay.current = () => {};
        };
      }, [autoplay, interval, api]);
    
      return { ...api.controls, index, userPaused, autoplaying: Boolean(autoplay) && !options.paused && !userPaused };
    }
    lib/gallery/use-gallery-renderer.ts
    import { useCallback, useEffect, useRef, useState } from "react";
    import { GalleryRenderer, type GalleryCard, type GalleryScene } from "@/lib/gallery/renderer";
    
    /**
     * - `idle`: not started yet (always the value on the server and the first client render)
     * - `ready`: WebGL is drawing
     * - `fallback`: WebGL2 is unavailable, the shaders failed or the context could not be restored
     */
    export type GalleryStatus = "idle" | "ready" | "fallback";
    
    export interface GalleryFrameState {
      /** Seconds since the renderer started (only advances while frames are drawn). */
      time: number;
      /** Seconds since the previous frame, capped at 0.1. */
      dt: number;
      /** Host size in CSS px. */
      width: number;
      height: number;
      reducedMotion: boolean;
      /**
       * Ambient motion (drift, autoplay) may run. False for all but one gallery preview on a page of
       * gallery cards: the others hold a still poster frame until they become the hovered or most
       * visible card. Input-driven motion always runs.
       */
      ambient: boolean;
      /** Scratch values that persist between frames (e.g. a drift velocity). */
      memory: Record<string, number>;
      renderer: GalleryRenderer;
    }
    
    export interface GalleryFrame {
      cards: readonly GalleryCard[];
      scene?: GalleryScene;
      /** Keep drawing frames. When false the loop sleeps until `invalidate()` is called. */
      animating?: boolean;
    }
    
    export interface UseGalleryRendererOptions {
      /** Image URLs; cards refer to them by index. */
      images: string[];
      /** Builds each frame's cards. Runs inside requestAnimationFrame, so it can read refs freely. */
      frame: (state: GalleryFrameState) => GalleryFrame;
      /** Highest device pixel ratio used (software WebGL is capped at 1). @default 2 */
      dpr?: number;
      /** Texture layer size in px. @default 512 */
      textureSize?: number;
      /** Texture layers kept on the GPU (least recently used are recycled). @default number of distinct images, up to 32 */
      maxTextures?: number;
    }
    
    interface PreviewLease {
      ratio: number;
      hovered: boolean;
      wake: () => void;
    }
    
    // Gallery previews (inside [data-preview-card], e.g. docs gallery cards) share one animation lease.
    const previews = new Set<PreviewLease>();
    let leader: PreviewLease | null = null;
    
    function electLeader() {
      let best: PreviewLease | null = null;
      for (const lease of previews) {
        if (lease.ratio <= 0) continue;
        if (!best || (lease.hovered && !best.hovered) || (lease.hovered === best.hovered && lease.ratio > best.ratio)) best = lease;
      }
      if (best === leader) return;
      const previous = leader;
      leader = best;
      previous?.wake();
      best?.wake();
    }
    
    /**
     * Runs a GalleryRenderer on a canvas the hook creates inside the element you attach `hostRef` to
     * (give it a size and `position: relative` or `absolute`). The loop only runs while the frame
     * reports `animating`, images fade in, or `invalidate()` is called, and never offscreen or in a
     * hidden tab. Context loss is recovered; everything is released on unmount.
     */
    export function useGalleryRenderer<T extends HTMLElement = HTMLDivElement>(options: UseGalleryRendererOptions) {
      const hostRef = useRef<T>(null);
      const rendererRef = useRef<GalleryRenderer | null>(null);
      const [status, setStatus] = useState<GalleryStatus>("idle");
      const latest = useRef(options);
      const wake = useRef<() => void>(() => {});
    
      useEffect(() => {
        latest.current = options;
        wake.current();
      });
    
      /** Requests a frame. Safe to call from any event handler, many times per frame. */
      const invalidate = useCallback(() => wake.current(), []);
    
      useEffect(() => {
        const host = hostRef.current;
        if (!host) return;
        const canvas = document.createElement("canvas");
        canvas.setAttribute("aria-hidden", "true");
        Object.assign(canvas.style, {
          position: "absolute",
          inset: "0",
          width: "100%",
          height: "100%",
          display: "block",
          pointerEvents: "none",
          opacity: "0",
          transition: "opacity 320ms ease-out",
        });
        host.appendChild(canvas);
    
        const reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
        const previewCard = host.closest<HTMLElement>("[data-preview-card]");
        const lease: PreviewLease | null = previewCard ? { ratio: 0, hovered: false, wake: () => wakeUp() } : null;
        const memory: Record<string, number> = {};
        let renderer: GalleryRenderer | null = null;
        let raf = 0;
        let last = 0;
        let clock = 0;
        let inView = false;
        let pageVisible = document.visibilityState === "visible";
        let lost = false;
        let needsResize = true;
        let shown = false;
    
        const resize = () => {
          if (!renderer) return;
          const cap = renderer.software ? 1 : Math.min(latest.current.dpr ?? 2, lease ? 1.5 : Infinity);
          renderer.resize(host.clientWidth, host.clientHeight, Math.min(window.devicePixelRatio || 1, Math.max(0.5, cap)));
          needsResize = false;
        };
    
        const frame = (now: number) => {
          raf = 0;
          if (!renderer || lost) return;
          const dt = last ? Math.min((now - last) / 1000, 0.1) : 1 / 60;
          last = now;
          clock += dt;
          if (needsResize) resize();
          renderer.setImages(latest.current.images);
          const result = latest.current.frame({
            time: clock,
            dt,
            width: host.clientWidth,
            height: host.clientHeight,
            reducedMotion: reduced.matches,
            ambient: !lease || leader === lease,
            memory,
            renderer,
          });
          const fading = renderer.render(result.cards, result.scene);
          if (!shown) {
            shown = true;
            canvas.style.opacity = "1";
          }
          if ((result.animating || fading) && inView && pageVisible) raf = requestAnimationFrame(frame);
          else last = 0;
        };
    
        const wakeUp = () => {
          if (!raf && renderer && !lost && inView && pageVisible) raf = requestAnimationFrame(frame);
        };
    
        const intersection = new IntersectionObserver(
          ([entry]) => {
            inView = entry.isIntersecting;
            if (lease) {
              lease.ratio = entry.isIntersecting ? entry.intersectionRatio : 0;
              electLeader();
            }
            wakeUp();
          },
          { rootMargin: "100px", threshold: [0, 0.25, 0.5, 0.75, 1] },
        );
        const onPreviewEnter = () => {
          if (!lease) return;
          lease.hovered = true;
          electLeader();
        };
        const onPreviewLeave = () => {
          if (!lease) return;
          lease.hovered = false;
          electLeader();
        };
        const resizeObserver = new ResizeObserver(() => {
          needsResize = true;
          wakeUp();
        });
        const onVisibility = () => {
          pageVisible = document.visibilityState === "visible";
          wakeUp();
        };
        const onLost = (event: Event) => {
          event.preventDefault();
          lost = true;
          if (raf) cancelAnimationFrame(raf);
          raf = 0;
        };
        const onRestored = () => {
          if (renderer?.restore()) {
            lost = false;
            needsResize = true;
            wakeUp();
          } else {
            setStatus("fallback");
          }
        };
    
        // Start on the next frame: keeps WebGL setup out of the effect body and off the hydration task.
        const start = requestAnimationFrame(() => {
          const { textureSize = 512, maxTextures, images } = latest.current;
          // Previews are small: smaller textures keep several on one page light on GPU memory.
          renderer = GalleryRenderer.create(canvas, {
            textureSize: lease ? Math.min(textureSize, 256) : textureSize,
            layers: maxTextures ?? Math.min(32, Math.max(1, new Set(images).size)),
          });
          if (!renderer) {
            canvas.remove();
            setStatus("fallback");
            return;
          }
          rendererRef.current = renderer;
          renderer.onImageLoad = wakeUp;
          wake.current = wakeUp;
          canvas.addEventListener("webglcontextlost", onLost);
          canvas.addEventListener("webglcontextrestored", onRestored);
          document.addEventListener("visibilitychange", onVisibility);
          intersection.observe(host);
          resizeObserver.observe(host);
          if (lease && previewCard) {
            previews.add(lease);
            previewCard.addEventListener("pointerenter", onPreviewEnter);
            previewCard.addEventListener("pointerleave", onPreviewLeave);
          }
          setStatus("ready");
        });
    
        return () => {
          cancelAnimationFrame(start);
          if (raf) cancelAnimationFrame(raf);
          wake.current = () => {};
          intersection.disconnect();
          resizeObserver.disconnect();
          document.removeEventListener("visibilitychange", onVisibility);
          canvas.removeEventListener("webglcontextlost", onLost);
          canvas.removeEventListener("webglcontextrestored", onRestored);
          if (lease && previewCard) {
            previews.delete(lease);
            previewCard.removeEventListener("pointerenter", onPreviewEnter);
            previewCard.removeEventListener("pointerleave", onPreviewLeave);
            if (leader === lease) leader = null;
            electLeader();
          }
          renderer?.destroy();
          rendererRef.current = null;
          canvas.remove();
        };
      }, []);
    
      return { hostRef, rendererRef, status, invalidate };
    }
    lib/gallery/use-gallery.ts
    import { useEffect, useRef } from "react";
    import { useGalleryInput, type GalleryInput, type UseGalleryInputOptions } from "@/lib/gallery/use-gallery-input";
    import { useGalleryRenderer, type GalleryFrame, type GalleryFrameState, type UseGalleryRendererOptions } from "@/lib/gallery/use-gallery-renderer";
    
    export interface UseGalleryOptions extends Omit<UseGalleryInputOptions, "invalidate">, Pick<UseGalleryRendererOptions, "images" | "dpr" | "textureSize" | "maxTextures"> {
      /** Builds each frame from the input state, e.g. card positions from `input.position()`. */
      frame: (state: GalleryFrameState, input: GalleryInput) => GalleryFrame;
    }
    
    /** The renderer and the input hook wired together: input changes request frames, frames read input. */
    export function useGallery<T extends HTMLElement = HTMLDivElement>({ images, dpr, textureSize, maxTextures, frame, ...inputOptions }: UseGalleryOptions) {
      const invalidateRef = useRef(() => {});
      const input = useGalleryInput({ ...inputOptions, invalidate: () => invalidateRef.current() });
      const renderer = useGalleryRenderer<T>({ images, dpr, textureSize, maxTextures, frame: (state) => frame(state, input) });
      const { invalidate } = renderer;
      useEffect(() => {
        invalidateRef.current = invalidate;
      }, [invalidate]);
      // `bind` is returned on its own: pass it straight to the root's ref (the compiler treats whatever
      // feeds a ref prop as a ref, so it must not be read off the input object during render).
      return { ...renderer, bind: input.bind, input };
    }
    lib/motion.ts
    import type { Transition, Variants } from "motion/react";
    
    /** Durations in seconds. */
    export const duration = {
      instant: 0.1,
      quick: 0.18,
      base: 0.28,
      slow: 0.45,
    } as const;
    
    /** Cubic-bezier curves. */
    export const ease = {
      /** Entering, UI response. */
      out: [0.22, 1, 0.36, 1],
      /** Moving on screen. */
      inOut: [0.65, 0, 0.35, 1],
      /** Sheets and the mobile menu. */
      drawer: [0.32, 0.72, 0, 1],
    } as const;
    
    /** Everyday UI: presses, icon swaps, popovers and modals. Exits run at `exitFactor` of the enter. */
    export const ui = {
      press: { scale: 0.96, duration: 0.12 },
      icon: { type: "spring", duration: 0.3, bounce: 0 },
      popover: { duration: 0.18, ease: ease.out },
      modal: { duration: 0.24, ease: ease.out },
      exitFactor: 0.6,
    } as const;
    
    /** Once-per-visit motion on the landing page: hero letters, the reel and the afterimage. */
    export const kinetic = {
      heroLetter: { duration: 0.6, ease: ease.out },
      /** Delay per letter, eased so the wave slows toward the end. */
      heroStep: 0.035,
      reelSwap: { type: "spring", duration: 0.5, bounce: 0.1 },
      /** Two afterimages: lag in seconds and opacity of each. */
      ghost: { lag: [0.04, 0.09], opacity: [0.14, 0.06] },
    } as const;
    
    /** Spring presets. */
    export const spring = {
      snappy: { type: "spring", stiffness: 500, damping: 30 },
      smooth: { type: "spring", stiffness: 260, damping: 26 },
      bouncy: { type: "spring", stiffness: 300, damping: 15 },
    } as const satisfies Record<string, Transition>;
    
    /** Stagger between siblings, in seconds (0.03–0.06). */
    export const stagger = {
      tight: 0.03,
      base: 0.045,
      loose: 0.06,
    } as const;
    
    /** Blur + fade + y-offset. Falls back to a plain fade under reduced motion. */
    export function enterVariants(reduceMotion?: boolean | null, y = 8): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.base } },
          exit: { opacity: 0, transition: { duration: duration.quick } },
        };
      }
      return {
        hidden: { opacity: 0, y, filter: "blur(4px)" },
        visible: {
          opacity: 1,
          y: 0,
          filter: "blur(0px)",
          transition: { duration: duration.base, ease: ease.out },
        },
        exit: {
          opacity: 0,
          y: -y / 2,
          filter: "blur(4px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
      };
    }
    
    /** Scale 0.96 → 1 for popovers, menus and dialogs. */
    export function overlayVariants(reduceMotion?: boolean | null): Variants {
      if (reduceMotion) {
        return {
          hidden: { opacity: 0 },
          visible: { opacity: 1, transition: { duration: duration.quick } },
          exit: { opacity: 0, transition: { duration: duration.instant } },
        };
      }
      return {
        hidden: { opacity: 0, scale: 0.96, filter: "blur(2px)" },
        visible: {
          opacity: 1,
          scale: 1,
          filter: "blur(0px)",
          transition: { duration: duration.quick, ease: ease.out },
        },
        exit: {
          opacity: 0,
          scale: 0.96,
          filter: "blur(2px)",
          transition: { duration: duration.instant + 0.02, ease: ease.out },
        },
      };
    }
    
    /** Parent variants that stagger their children in. */
    export function staggerVariants(gap: number = stagger.base, delayChildren = 0): Variants {
      return {
        hidden: {},
        visible: { transition: { staggerChildren: gap, delayChildren } },
        exit: { transition: { staggerChildren: gap / 2, staggerDirection: -1 } },
      };
    }
  3. Copy the source

    components/ui/infinite-canvas-gallery/infinite-canvas-gallery.tsx
    "use client";
    
    import { useCallback, useEffect, useImperativeHandle, useRef, useState, type CSSProperties, type KeyboardEvent, type Ref } from "react";
    import { AnimatePresence, motion } from "motion/react";
    import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, X } from "lucide-react";
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { GalleryA11y } from "@/lib/gallery/gallery-a11y";
    import { chromeScheme } from "@/lib/gallery/gallery-chrome";
    import { GalleryFallback } from "@/lib/gallery/gallery-fallback";
    import { clamp, lerp, smoothstep, wrap } from "@/lib/gallery/math";
    import type { GalleryCard } from "@/lib/gallery/renderer";
    import type { GalleryHandle, GalleryItem } from "@/lib/gallery/types";
    import { useGallery } from "@/lib/gallery/use-gallery";
    
    export interface InfiniteCanvasHandle extends GalleryHandle {
      /** Pans by a number of cells (keyboard-sized steps). */
      pan: (columns: number, rows: number) => void;
      /** Closes the open image. */
      close: () => void;
    }
    
    export interface InfiniteCanvasGalleryProps {
      /** Images; they tile endlessly in every direction. */
      items: GalleryItem[];
      /** Item the keyboard list starts on. @default 0 */
      startIndex?: number;
      /** Drift slowly on its own until touched. @default false */
      autoplay?: boolean;
      /** Drift speed in px per second when autoplay is on. @default 18 */
      interval?: number;
      /** Pause the drift while hovered. @default true */
      pauseOnHover?: boolean;
      /** Drag to pan in any direction, with momentum. @default true */
      draggable?: boolean;
      /** The wheel and trackpad pan the canvas (they then stop the page scrolling over it). @default false */
      wheel?: boolean;
      /** Caption and close button under an open image. @default true */
      captions?: boolean;
      /** Arrow buttons that pan one cell at a time. @default true */
      controls?: boolean;
      /** Column width in px; 0 sizes it to the stage. @default 0 */
      cellSize?: number;
      /** Space between cards, as a fraction of the column width. @default 0.08 */
      gap?: number;
      /** Depth difference between columns, 0–0.5: near columns move faster and look larger. @default 0.18 */
      depth?: number;
      /** Card corner radius, px. @default 10 */
      radius?: number;
      /** Seed for the column layout. @default 3 */
      seed?: number;
      /** Background color; captions and controls switch to light text on dark colors. @default "transparent" */
      backgroundColor?: string;
      /** Stops the drift (input still works). @default false */
      paused?: boolean;
      /** Highest device pixel ratio rendered. @default 2 */
      dpr?: number;
      /** Accessible name of the gallery. @default "Gallery" */
      ariaLabel?: string;
      /** Classes for the root, which fills its parent (at least 420px tall). */
      className?: string;
      /** Inline styles for the root. */
      style?: CSSProperties;
      /** Fires when the keyboard list's active item changes. */
      onIndexChange?: (index: number) => void;
      /** Fires when an image opens. */
      onSelect?: (item: GalleryItem, index: number) => void;
      /** Imperative `next()`, `previous()`, `goTo()`, `pan(columns, rows)` and `close()`. */
      ref?: Ref<InfiniteCanvasHandle>;
    }
    
    const ROWS = 6;
    const DEPTH_CYCLE = 5;
    
    /** Deterministic 0–1 hash of two integers and a seed. */
    function hash(a: number, b: number, seed: number) {
      let h = Math.imul(a + 0x9e37, 374761393) ^ Math.imul(b + 0x85eb, 668265263) ^ Math.imul(seed + 1, 2246822519);
      h = Math.imul(h ^ (h >>> 13), 1274126177);
      return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
    }
    
    type OpenState = { id: number; item: number } | null;
    
    /**
     * An endless, pannable masonry grid. Columns sit at slightly different depths, so they slide past
     * each other with parallax; momentum carries a throw. Click an image to open it. Rendered with the
     * shared WebGL2 gallery engine.
     */
    export function InfiniteCanvasGallery({
      items,
      startIndex = 0,
      autoplay = false,
      interval = 18,
      pauseOnHover = true,
      draggable = true,
      wheel = false,
      captions = true,
      controls = true,
      cellSize = 0,
      gap = 0.08,
      depth = 0.18,
      radius = 10,
      seed = 3,
      backgroundColor = "transparent",
      paused = false,
      dpr = 2,
      ariaLabel = "Gallery",
      className,
      style,
      onIndexChange,
      onSelect,
      ref,
    }: InfiniteCanvasGalleryProps) {
      const [open, setOpen] = useState<OpenState>(null);
      const scheme = chromeScheme(backgroundColor);
      const panState = useRef({ x: 0, y: 0, vx: 0, vy: 0, last: 0, targetX: 0, targetY: 0, stepping: false, cell: 220 });
      const count = items.length || 1;
    
      const openImage = useCallback(
        (next: OpenState) => {
          if (next) {
            setOpen(next);
            onSelect?.(items[next.item], next.item);
          } else setOpen(null);
        },
        [items, onSelect],
      );
    
      const pan = useCallback((columns: number, rows: number) => {
        const state = panState.current;
        state.targetX = state.x - columns * state.cell;
        state.targetY = state.y + rows * state.cell;
        state.vx = 0;
        state.vy = 0;
        state.stepping = true;
      }, []);
    
      const { hostRef, bind, status, input, rendererRef, invalidate } = useGallery({
        images: items.map((item) => item.src),
        count,
        loop: true,
        startIndex,
        draggable,
        wheel: false,
        pausable: autoplay,
        pauseOnHover,
        paused,
        dpr,
        dragDelta: (move) => {
          const state = panState.current;
          const now = performance.now();
          const elapsed = Math.max(0.008, (now - (state.last || now - 16)) / 1000);
          state.last = now;
          state.stepping = false;
          state.x += move.dx;
          state.y -= move.dy;
          state.vx = clamp(move.dx / elapsed, -3000, 3000);
          state.vy = clamp(-move.dy / elapsed, -3000, 3000);
          return 0;
        },
        onIndexChange,
        onTap: (point) => {
          if (open) return openImage(null);
          const hit = rendererRef.current?.pick(point.x, point.y);
          if (hit && hit.id >= 0) openImage({ id: hit.id, item: Math.floor(hit.id) % 1000 });
        },
        frame: ({ dt, width, height, reducedMotion, ambient, memory }, gallery) => {
          const state = panState.current;
          let moving = gallery.update(dt);
          const cell = cellSize > 0 ? cellSize : clamp(Math.min(width, height) * 0.3, 130, 260);
          const space = cell * clamp(gap, 0, 0.4);
          const pitch = cell + space;
          state.cell = pitch;
          if (state.stepping) {
            // Keyboard and button steps glide to their target.
            const k = reducedMotion ? 1 : 1 - Math.exp(-dt * 9);
            state.x += (state.targetX - state.x) * k;
            state.y += (state.targetY - state.y) * k;
            if (Math.abs(state.targetX - state.x) + Math.abs(state.targetY - state.y) < 0.5) state.stepping = false;
            moving = true;
          } else if (!gallery.dragging() && (state.vx || state.vy)) {
            state.x += state.vx * dt;
            state.y += state.vy * dt;
            const decay = Math.exp(-dt / 0.45);
            state.vx = Math.abs(state.vx * decay) < 2 ? 0 : state.vx * decay;
            state.vy = Math.abs(state.vy * decay) < 2 ? 0 : state.vy * decay;
            moving = true;
          }
          const drifting = autoplay && !paused && !gallery.userPaused && ambient && !reducedMotion && !open && gallery.canAutoplay();
          if (drifting) {
            state.x -= interval * 0.6 * dt;
            state.y += interval * dt;
            moving = true;
          }
    
          // Open spring.
          const openTarget = open ? 1 : 0;
          memory.openT ??= 0;
          if (reducedMotion) memory.openT = openTarget;
          else {
            const h = Math.min(dt, 1 / 30);
            memory.openV = (memory.openV ?? 0) + (-110 * (memory.openT - openTarget) - 21 * (memory.openV ?? 0)) * h;
            memory.openT += memory.openV * h;
            if (Math.abs(memory.openT - openTarget) < 0.001 && Math.abs(memory.openV) < 0.01) {
              memory.openT = openTarget;
              memory.openV = 0;
            } else moving = true;
          }
          if (open) memory.openId = open.id;
          const openT = smoothstep(0, 1, memory.openT);
          const openId = open ? open.id : memory.openT > 0.001 ? (memory.openId ?? -1) : -1;
    
          const cards: GalleryCard[] = [];
          const columnsLeft = Math.ceil(width / 2 / pitch) + 2;
          const firstColumn = Math.floor(-state.x / pitch) - columnsLeft;
          const lastColumn = Math.floor(-state.x / pitch) + columnsLeft;
          let openCard: GalleryCard | null = null;
          for (let c = firstColumn; c <= lastColumn; c++) {
            const lane = wrap(c, DEPTH_CYCLE);
            const near = 1 + (hash(lane, 0, seed) - 0.5) * 2 * clamp(depth, 0, 0.5);
            const x = c * pitch + state.x * near;
            if (Math.abs(x) > width / 2 + pitch * 1.5) continue;
            // A repeating column of ROWS cards with seeded heights, staggered per column.
            const heights = Array.from({ length: ROWS }, (_, r) => cell * (0.78 + hash(lane, r, seed) * 0.6));
            const tile = heights.reduce((sum, h) => sum + h + space, 0);
            const offset = state.y * near + hash(lane, 99, seed) * tile;
            const firstTile = Math.floor((-height / 2 - offset) / tile) - 1;
            const lastTile = Math.floor((height / 2 - offset) / tile) + 1;
            for (let t = firstTile; t <= lastTile; t++) {
              let y = offset + t * tile + height / 2;
              for (let r = 0; r < ROWS; r++) {
                const h = heights[r];
                const cy = y - h / 2;
                y -= h + space;
                if (cy < -height / 2 - h || cy > height / 2 + h) continue;
                const item = wrap(c * 7 + (t * ROWS + r) * 3, count);
                // Ids pack the item with a unique instance number so picks know both.
                const id = item + 1000 * (wrap(c, 997) * 131 + wrap(t * ROWS + r, 131));
                const card: GalleryCard = {
                  image: item,
                  id,
                  x,
                  y: cy,
                  z: (near - 1) * 60,
                  scale: near,
                  width: cell,
                  height: h,
                  radius,
                  shadow: 0.35,
                  dim: openT * 0.5,
                };
                if (id === openId) openCard = card;
                cards.push(card);
              }
            }
          }
          if (openCard) {
            const fit = Math.min((height * 0.7) / openCard.height, (width * 0.8) / openCard.width);
            openCard.x = lerp(openCard.x, 0, openT);
            openCard.y = lerp(openCard.y, height * 0.04, openT);
            openCard.z = 300 * openT;
            openCard.scale = lerp(openCard.scale ?? 1, fit, openT);
            openCard.radius = lerp(radius, 14 / fit, openT);
            openCard.dim = 0;
            openCard.shadow = 1;
          } else if (open) {
            // Opened from the list: rise from the center.
            const h = cell * 1.1;
            const fit = Math.min((height * 0.7) / h, (width * 0.8) / cell);
            cards.push({ image: open.item, id: -1, x: 0, y: height * 0.04, z: 300, scale: lerp(0.4, fit, openT), width: cell, height: h, radius: 14 / fit, opacity: openT, shadow: 1 });
          }
          return { cards, animating: moving, scene: { fov: 30, edgeFade: [0.05, 0.07], shadowOffset: 8, shadowBlur: 14, shadowOpacity: 0.2 } };
        },
      });
    
      // Optional wheel / trackpad panning (off by default so the page keeps scrolling).
      useEffect(() => {
        const element = hostRef.current?.parentElement;
        if (!element || !wheel) return;
        const onWheel = (event: WheelEvent) => {
          event.preventDefault();
          const state = panState.current;
          state.stepping = false;
          state.x -= event.deltaX;
          state.y += event.deltaY;
          invalidate();
        };
        element.addEventListener("wheel", onWheel, { passive: false });
        return () => element.removeEventListener("wheel", onWheel);
      }, [wheel, invalidate, hostRef]);
    
      const { next, previous, goTo } = input;
      useImperativeHandle(ref, () => ({ next, previous, goTo: (index: number) => goTo(index), pan: (c: number, r: number) => (pan(c, r), invalidate()), close: () => openImage(null) }), [next, previous, goTo, pan, invalidate, openImage]);
    
      const step = (columns: number, rows: number) => {
        pan(columns, rows);
        invalidate();
      };
      const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
        if (event.key === "Escape" && open) return openImage(null);
        // Arrows pan when the canvas itself has focus; inside the image list they move between items.
        if (event.target !== event.currentTarget) return;
        const moves: Record<string, [number, number]> = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
        if (event.key in moves && !open) {
          event.preventDefault();
          step(...moves[event.key]);
        }
      };
    
      const fallback = status === "fallback";
      const openedItem = open ? items[open.item] : undefined;
      const pad = cn(
        "grid size-8 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring",
        scheme === "dark" ? "text-white hover:bg-white/10" : "text-foreground hover:bg-surface-2",
      );
      return (
        <div
          ref={bind}
          role="group"
          aria-roledescription="carousel"
          aria-label={ariaLabel}
          tabIndex={-1}
          onKeyDown={onKeyDown}
          className={cn("relative size-full min-h-[420px] touch-none overflow-hidden rounded-[inherit] outline-none select-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", className)}
          style={{ backgroundColor, ...style }}
        >
          <div ref={hostRef} className="absolute inset-0">
            {fallback ? <GalleryFallback items={items} label={ariaLabel} onSelect={onSelect} className="absolute inset-0" /> : null}
          </div>
          {fallback ? null : <GalleryA11y items={items} index={input.index} label={ariaLabel} live={!autoplay} onActivate={(i) => openImage({ id: -2 - i, item: i })} />}
          <AnimatePresence>
            {captions && openedItem && !fallback ? (
              <motion.div
                key={open?.id}
                data-gallery-control
                initial={{ opacity: 0, y: 8 }}
                animate={{ opacity: 1, y: 0 }}
                exit={{ opacity: 0, y: 4 }}
                transition={{ duration: duration.base, ease: ease.out }}
                className={cn(
                  "absolute bottom-4 left-1/2 flex max-w-[calc(100%-2rem)] -translate-x-1/2 items-center gap-3 rounded-full border py-1.5 pr-1.5 pl-4 shadow-lg backdrop-blur",
                  scheme === "dark" ? "border-white/15 bg-zinc-900/80 text-white" : "border-border bg-background/90 text-foreground",
                )}
              >
                <p className="min-w-0 truncate text-sm">
                  <span className="font-medium">{openedItem.title ?? openedItem.alt}</span>
                  {openedItem.subtitle ? <span className={scheme === "dark" ? "text-white/65" : "text-muted-foreground"}> · {openedItem.subtitle}</span> : null}
                </p>
                <button type="button" aria-label="Close image" onClick={() => openImage(null)} className={pad}>
                  <X className="size-4" aria-hidden="true" />
                </button>
              </motion.div>
            ) : null}
          </AnimatePresence>
          {controls && !fallback ? (
            <div
              data-gallery-control
              role="group"
              aria-label="Pan the canvas"
              inert={Boolean(open)}
              className={cn(
                "absolute right-3 bottom-3 grid grid-cols-3 gap-0.5 rounded-2xl border p-1 shadow-sm backdrop-blur transition-opacity",
                open ? "opacity-0" : "opacity-100",
                scheme === "dark" ? "border-white/15 bg-zinc-900/70" : "border-border bg-background/85",
              )}
            >
              <span />
              <button type="button" data-gallery-control aria-label="Pan up" onClick={() => step(0, -1)} className={pad}>
                <ArrowUp className="size-4" aria-hidden="true" />
              </button>
              <span />
              <button type="button" data-gallery-control aria-label="Pan left" onClick={() => step(-1, 0)} className={pad}>
                <ArrowLeft className="size-4" aria-hidden="true" />
              </button>
              <button type="button" data-gallery-control aria-label="Pan down" onClick={() => step(0, 1)} className={pad}>
                <ArrowDown className="size-4" aria-hidden="true" />
              </button>
              <button type="button" data-gallery-control aria-label="Pan right" onClick={() => step(1, 0)} className={pad}>
                <ArrowRight className="size-4" aria-hidden="true" />
              </button>
            </div>
          ) : null}
        </div>
      );
    }

Usage

import { InfiniteCanvasGallery } from "@/components/ui/infinite-canvas-gallery/infinite-canvas-gallery";

<div className="h-[32rem]">
  <InfiniteCanvasGallery items={photos} depth={0.2} onSelect={(item) => console.log(item.title)} />
</div>

API reference

PropTypeDefaultDescription
items*GalleryItem[]–Images; they tile endlessly in every direction.
startIndexnumber0Item the keyboard list starts on.
autoplaybooleanfalseDrift slowly on its own until touched.
intervalnumber18Drift speed in px per second when autoplay is on.
pauseOnHoverbooleantruePause the drift while hovered.
draggablebooleantrueDrag to pan in any direction, with momentum.
wheelbooleanfalseThe wheel and trackpad pan the canvas (they then stop the page scrolling over it).
captionsbooleantrueCaption and close button under an open image.
controlsbooleantrueArrow buttons that pan one cell at a time.
cellSizenumber0Column width in px; 0 sizes it to the stage.
gapnumber0.08Space between cards, as a fraction of the column width.
depthnumber0.18Depth difference between columns, 0–0.5: near columns move faster and look larger.
radiusnumber10Card corner radius, px.
seednumber3Seed for the column layout.
backgroundColorstring"transparent"Background color; captions and controls switch to light text on dark colors.
pausedbooleanfalseStops the drift (input still works).
dprnumber2Highest device pixel ratio rendered.
ariaLabelstring"Gallery"Accessible name of the gallery.
classNamestring–Classes for the root, which fills its parent (at least 420px tall).
styleCSSProperties–Inline styles for the root.
onIndexChange(index: number) => void–Fires when the keyboard list's active item changes.
onSelect(item: GalleryItem, index: number) => void–Fires when an image opens.
refRef<InfiniteCanvasHandle>–Imperative `next()`, `previous()`, `goTo()`, `pan(columns, rows)` and `close()`.
  • The grid tiles forever: every column repeats a seeded run of six cards with varied heights, staggered per column, and only the cards in view are generated each frame.
  • Columns sit at slightly different depths (depth): near ones scale up and pan faster than far ones, which gives the parallax. A throw keeps its momentum and glides to a stop.
  • wheel is off by default because an endless canvas has no edge to hand scrolling back to the page. Turn it on for full-screen use.

Accessibility

  • The canvas is decorative (aria-hidden). A visually hidden list of buttons opens any image enlarged with its caption; Escape or the close button puts it back.
  • The arrow buttons pan one cell at a time, and the arrow keys do the same once the canvas has focus (click it first). Inside the image list the arrow keys move between images instead.
  • With reduced motion there is no drift and keyboard steps jump straight to their cell.
  • Without WebGL the images render as a plain, swipeable scroll-snap list.