Skip to content

Formation Gallery

Up to 160 cards flowing between rings, spiral, globe and field.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/formation-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/color.ts
    export type Vec3 = [r: number, g: number, b: number];
    
    /**
     * Parses #rgb, #rrggbb or #rrggbbaa (alpha is ignored) into 0–255 channels.
     * Anything else falls back to black, so a typo never crashes a shader.
     */
    export function hexToRgb(hex: string): Vec3 {
      let value = hex.trim().replace(/^#/, "");
      if (value.length === 3 || value.length === 4) value = [...value.slice(0, 3)].map((c) => c + c).join("");
      if (!/^[\da-f]{6}([\da-f]{2})?$/i.test(value)) return [0, 0, 0];
      const n = Number.parseInt(value.slice(0, 6), 16);
      return [(n >> 16) & 255, (n >> 8) & 255, n & 255];
    }
    
    /** Hex color as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function hexToVec3(hex: string): Vec3 {
      const [r, g, b] = hexToRgb(hex);
      return [r / 255, g / 255, b / 255];
    }
    
    /**
     * Any CSS color the browser understands (hex, rgb(), oklch(), color-mix() or a variable such as
     * `var(--primary)`) as 0–255 channels, computed in the context of `element` so theme variables
     * resolve. Hex takes a fast path; everything else is computed once per call, so cache the result
     * instead of calling this every frame. Browser only; unreadable values fall back to black.
     */
    export function cssColorToRgb(color: string, element: Element): Vec3 {
      const value = color.trim();
      if (/^#[\da-f]{3,8}$/i.test(value)) return hexToRgb(value);
      // Children of a canvas still get computed styles, but a regular parent is the safer host.
      const host = element instanceof HTMLCanvasElement ? (element.parentElement ?? document.body) : element;
      const probe = document.createElement("span");
      probe.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;visibility:hidden";
      probe.style.color = value;
      host.appendChild(probe);
      const computed = getComputedStyle(probe).color;
      probe.remove();
      // A 1×1 canvas turns any computed color space (oklch, color(srgb …)) into sRGB bytes.
      const ctx = document.createElement("canvas").getContext("2d", { willReadFrequently: true });
      if (!ctx) return [0, 0, 0];
      ctx.fillStyle = "#000";
      ctx.fillStyle = computed;
      ctx.fillRect(0, 0, 1, 1);
      const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
      return [r, g, b];
    }
    
    /**
     * Calls `onChange` when the page theme may have changed: a class or data-theme change on <html> or
     * <body> (shadcn's `.dark`, next-themes, a data-theme switch) or a change of the OS color scheme.
     * Resolved colors are cached, so re-resolve them here. Browser only; returns the unsubscribe.
     */
    export function observeTheme(onChange: () => void): () => void {
      const observer = new MutationObserver(onChange);
      const options = { attributes: true, attributeFilter: ["class", "data-theme"] };
      observer.observe(document.documentElement, options);
      if (document.body) observer.observe(document.body, options);
      const scheme = window.matchMedia("(prefers-color-scheme: dark)");
      scheme.addEventListener("change", onChange);
      return () => {
        observer.disconnect();
        scheme.removeEventListener("change", onChange);
      };
    }
    
    /** `cssColorToRgb` as a normalized vec3 (each channel 0–1) for shader uniforms. */
    export function cssColorToVec3(color: string, element: Element): Vec3 {
      const [r, g, b] = cssColorToRgb(color, element);
      return [r / 255, g / 255, b / 255];
    }
    
    const toLinear = (c: number) => (c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4);
    const toGamma = (c: number) => (c <= 0.0031308 ? 12.92 * c : 1.055 * c ** (1 / 2.4) - 0.055);
    
    /** 0–255 sRGB to OKLCH: lightness 0–1, chroma, hue in radians. */
    export function rgbToOklch([r, g, b]: Vec3): Vec3 {
      const [lr, lg, lb] = [r, g, b].map((v) => toLinear(v / 255));
      const l = Math.cbrt(0.4122214708 * lr + 0.5363325363 * lg + 0.0514459929 * lb);
      const m = Math.cbrt(0.2119034982 * lr + 0.6806995451 * lg + 0.1073969566 * lb);
      const s = Math.cbrt(0.0883024619 * lr + 0.2817188376 * lg + 0.6299787005 * lb);
      const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
      const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
      const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
      return [L, Math.hypot(A, B), Math.atan2(B, A)];
    }
    
    /** OKLCH back to 0–255 sRGB, clamped into gamut. */
    export function oklchToRgb([L, C, h]: Vec3): Vec3 {
      const A = C * Math.cos(h);
      const B = C * Math.sin(h);
      const l = (L + 0.3963377774 * A + 0.2158037573 * B) ** 3;
      const m = (L - 0.1055613458 * A - 0.0638541728 * B) ** 3;
      const s = (L - 0.0894841775 * A - 1.291485548 * B) ** 3;
      const rgb = [4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s];
      return rgb.map((v) => Math.round(Math.min(1, Math.max(0, toGamma(Math.min(1, Math.max(0, v))))) * 255)) as Vec3;
    }
    
    /**
     * Mixes two 0–255 colors in OKLCH, taking the short way around the hue circle, so midpoints keep
     * their chroma instead of turning muddy the way an RGB mix does.
     */
    export function mixOklch(a: Vec3, b: Vec3, t: number): Vec3 {
      const [L1, C1, h1] = rgbToOklch(a);
      const [L2, C2, h2] = rgbToOklch(b);
      // A near-gray end has no meaningful hue: borrow the other end's.
      const ha = C1 < 0.02 ? h2 : h1;
      const hb = C2 < 0.02 ? h1 : h2;
      let dh = hb - ha;
      if (dh > Math.PI) dh -= Math.PI * 2;
      if (dh < -Math.PI) dh += Math.PI * 2;
      return oklchToRgb([L1 + (L2 - L1) * t, C1 + (C2 - C1) * t, ha + dh * t]);
    }
    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/formation-gallery/formation-gallery.tsx
    "use client";
    
    import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react";
    import { AnimatePresence, motion } from "motion/react";
    import { X } from "lucide-react";
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cssColorToVec3, observeTheme } from "@/lib/color";
    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 type Formation = "rings" | "spiral" | "globe" | "field";
    export type FormationTransition = "gather" | "swirl" | "direct";
    
    export interface FormationGalleryHandle extends GalleryHandle {
      /** Switches to a formation by name or by its position in `formations`. */
      setFormation: (formation: Formation | number) => void;
      /** Puts an open card back. */
      close: () => void;
    }
    
    export interface FormationGalleryProps {
      /** Photos on the cards; they repeat when there are more cards than items. */
      items: GalleryItem[];
      /** Headline or call to action kept in the empty center. It fades while a card is open. */
      children?: ReactNode;
      /** Number of cards, up to 160. @default 96 */
      count?: number;
      /** Formations to cycle through, in order. @default ["rings", "spiral", "globe", "field"] */
      formations?: Formation[];
      /** How cards travel between formations. @default "gather" */
      transition?: FormationTransition;
      /** Item the keyboard starts on. @default 0 */
      startIndex?: number;
      /** Move to the next formation every `interval`. @default true */
      autoplay?: boolean;
      /** Milliseconds per formation. @default 6500 */
      interval?: number;
      /** Concentric rings in the "rings" formation. @default 4 */
      rings?: number;
      /** Card size in px; 0 sizes cards to the stage and count. @default 0 */
      cardSize?: number;
      /** Empty center kept for children, as a fraction of the smaller side. @default 0.36 */
      clearance?: number;
      /** Rotation speed of rings, spiral and globe in degrees per second. @default 8 */
      spin?: number;
      /** Card shape: "auto" uses rounded squares in rings and spiral, circles in globe and field. @default "auto" */
      shape?: "auto" | "square" | "circle";
      /** Corner radius of square cards, px. @default 8 */
      cardRadius?: number;
      /** Tilts the whole formation toward the pointer, in degrees; 0 turns it off. @default 10 */
      tilt?: number;
      /** Scale of the card under the pointer. @default 1.6 */
      hoverScale?: number;
      /** Soft shadow strength, 0–1. @default 0.45 */
      shadow?: number;
      /** Ring around the hovered card (any CSS color, including a theme variable). @default "var(--primary)" */
      accentColor?: string;
      /** Click a card to enlarge it in the center with its caption. @default true */
      openOnClick?: boolean;
      /** Seed for the card order and the field layout. @default 7 */
      seed?: number;
      /** Hovering pauses autoplay and slows the spin. @default true */
      pauseOnHover?: boolean;
      /** Drag spins rings, spiral and globe, or pans the field, with inertia. @default true */
      draggable?: boolean;
      /** The wheel spins the formation (it then keeps the page from scrolling over the gallery). @default false */
      wheel?: boolean;
      /** Caption and close button under an open card. @default true */
      captions?: boolean;
      /** Formation buttons along the bottom. @default true */
      controls?: boolean;
      /** Background color; text switches to light on dark colors. @default "transparent" */
      backgroundColor?: string;
      /** Stops spin, drift and autoplay (input still works). @default false */
      paused?: boolean;
      /** Highest device pixel ratio rendered. @default 2 */
      dpr?: number;
      /** Accessible name of the gallery. @default "Photos" */
      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's active item changes. */
      onIndexChange?: (index: number) => void;
      /** Fires when a card opens. */
      onSelect?: (item: GalleryItem, index: number) => void;
      /** Fires when the formation changes. */
      onFormationChange?: (formation: Formation, index: number) => void;
      /** Fires when a card opens (with its item) or closes (null). */
      onOpen?: (item: GalleryItem | null, index: number) => void;
      /** Imperative `next()`, `previous()`, `goTo()`, `setFormation()` and `close()`. */
      ref?: Ref<FormationGalleryHandle>;
    }
    
    const ALL: Formation[] = ["rings", "spiral", "globe", "field"];
    const LABELS: Record<Formation, string> = { rings: "Rings", spiral: "Spiral", globe: "Globe", field: "Field" };
    const GOLDEN = Math.PI * (3 - Math.sqrt(5));
    const MOVE = 1.35;
    const STAGGER = 0.45;
    
    function mulberry32(seed: number) {
      let a = seed >>> 0;
      return () => {
        a = (a + 0x6d2b79f5) >>> 0;
        let t = a;
        t = Math.imul(t ^ (t >>> 15), t | 1);
        t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
        return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
      };
    }
    
    function toRgb(color: string): [number, number, number] {
      const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
      if (!hex) return [0.145, 0.388, 0.922];
      const h = hex[1].length === 3 ? [...hex[1]].map((c) => c + c).join("") : hex[1];
      return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number];
    }
    
    const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
    
    interface Spot {
      x: number;
      y: number;
      z: number;
      round: number;
      opacity: number;
      /** Cards that face the camera regardless of group tilt (globe). */
      billboard: boolean;
    }
    
    interface Stage {
      inner: number;
      outer: number;
      stretch: number;
      width: number;
      height: number;
      angle: number;
      panX: number;
      panY: number;
      time: number;
      size: number;
      count: number;
      ringCount: number;
      shape: "auto" | "square" | "circle";
      ringOf: Int32Array;
      slotOf: Int32Array;
      ringSizes: Int32Array;
      field: Float32Array;
    }
    
    const roundFor = (formation: Formation, shape: Stage["shape"]) => (shape === "circle" ? 1 : shape === "square" ? 0 : formation === "globe" || formation === "field" ? 1 : 0);
    
    /** Fades cards that drift into the center clearance (front of the globe, a panned field). */
    const clearanceFade = (x: number, y: number, inner: number, stretch: number) => smoothstep(inner * 0.72, inner * 1.05, Math.hypot(x / stretch, y));
    
    /** Where card k sits in a formation right now. */
    function spotFor(formation: Formation, k: number, s: Stage): Spot {
      const round = roundFor(formation, s.shape);
      if (formation === "rings") {
        const ring = s.ringOf[k];
        const n = s.ringSizes[ring];
        const radius = s.inner + s.size * 0.7 + (s.outer - s.inner - s.size * 0.7) * ((ring + 0.5) / s.ringCount);
        const direction = ring % 2 ? -1 : 1;
        const theta = (s.slotOf[k] / n) * Math.PI * 2 + s.angle * direction * (1 + ring * 0.18) + ring * 0.4;
        return { x: Math.cos(theta) * radius * s.stretch, y: Math.sin(theta) * radius, z: 0, round, opacity: 1, billboard: false };
      }
      if (formation === "spiral") {
        const theta = k * GOLDEN + s.angle * 0.6;
        const radius = s.inner + s.size * 0.6 + (s.outer - s.inner - s.size * 0.6) * Math.sqrt((k + 0.5) / s.count);
        return { x: Math.cos(theta) * radius * s.stretch, y: Math.sin(theta) * radius, z: 0, round, opacity: 1, billboard: false };
      }
      if (formation === "globe") {
        const g = Math.min(s.width, s.height) * 0.44;
        const y = 1 - ((k + 0.5) / s.count) * 2;
        const r = Math.sqrt(1 - y * y);
        const phi = k * GOLDEN + s.angle;
        let x = Math.cos(phi) * r;
        let z = Math.sin(phi) * r;
        // A slight forward tilt so the poles read as a sphere.
        const tiltX = 0.32;
        const yy = y * Math.cos(tiltX) - z * Math.sin(tiltX);
        z = y * Math.sin(tiltX) + z * Math.cos(tiltX);
        x *= g;
        const depth = z;
        const fade = depth > -0.15 ? clearanceFade(x, yy * g, s.inner, 1) : 1;
        return { x, y: yy * g, z: depth * g, round, opacity: (0.42 + 0.58 * smoothstep(-1, 0.6, depth)) * fade, billboard: true };
      }
      // Field: seeded spots that drift; drag pans them with wraparound.
      const spanX = s.width * 1.15;
      const spanY = s.height * 1.15;
      const fx = s.field[k * 3];
      const fy = s.field[k * 3 + 1];
      const phase = s.field[k * 3 + 2];
      const driftX = Math.sin(s.time * 0.21 + phase) * s.size * 0.6;
      const driftY = Math.cos(s.time * 0.17 + phase * 1.3) * s.size * 0.5;
      const x = wrap(fx * spanX + s.panX + driftX + spanX / 2, spanX) - spanX / 2;
      const y = wrap(fy * spanY + s.panY + driftY + spanY / 2, spanY) - spanY / 2;
      return { x, y, z: phase * 2, round, opacity: clearanceFade(x, y, s.inner, Math.max(1, s.width / s.height) * 0.9), billboard: false };
    }
    
    type OpenState = { card: number; item: number } | null;
    
    /**
     * Up to 160 small photo cards that flow between formations (rings, spiral, globe, field) around an
     * empty center kept for a headline. Rendered with the shared WebGL2 gallery engine; a hidden button
     * list carries the accessible gallery.
     */
    export function FormationGallery({
      items,
      children,
      count = 96,
      formations = ALL,
      transition = "gather",
      startIndex = 0,
      autoplay = true,
      interval = 6500,
      rings = 4,
      cardSize = 0,
      clearance = 0.36,
      spin = 8,
      shape = "auto",
      cardRadius = 8,
      tilt = 10,
      hoverScale = 1.6,
      shadow = 0.45,
      accentColor = "var(--primary)",
      openOnClick = true,
      seed = 7,
      pauseOnHover = true,
      draggable = true,
      wheel = false,
      captions = true,
      controls = true,
      backgroundColor = "transparent",
      paused = false,
      dpr = 2,
      ariaLabel = "Photos",
      className,
      style,
      onIndexChange,
      onSelect,
      onFormationChange,
      onOpen,
      ref,
    }: FormationGalleryProps) {
      const list = formations.length ? formations : ALL;
      const cards = clamp(Math.round(count), 1, 160);
      const [formationIndex, setFormationIndex] = useState(0);
      const [open, setOpen] = useState<OpenState>(null);
      const scheme = chromeScheme(backgroundColor);
      // Hover ring color; a theme variable is resolved against the gallery once it has mounted.
      const accent = useRef(toRgb(accentColor));
      const formation = list[formationIndex % list.length];
      // Drag spin/pan with inertia, shared between pointer handlers and the render loop.
      const drag = useRef({ angle: 0, angleV: 0, panX: 0, panY: 0, panVX: 0, panVY: 0, last: 0 });
    
      // Per-card data that only depends on the seed, the count and the ring split.
      const plan = useMemo(() => {
        const random = mulberry32(seed);
        const order = Array.from({ length: cards }, (_, k) => k % Math.max(1, items.length));
        for (let i = order.length - 1; i > 0; i--) {
          const j = Math.floor(random() * (i + 1));
          [order[i], order[j]] = [order[j], order[i]];
        }
        const field = new Float32Array(cards * 3);
        for (let k = 0; k < cards; k++) {
          field[k * 3] = random() - 0.5;
          field[k * 3 + 1] = random() - 0.5;
          field[k * 3 + 2] = random() * Math.PI * 2;
        }
        const delays = Float32Array.from({ length: cards }, () => random() * STAGGER);
        const gather = Float32Array.from({ length: cards * 2 }, () => random() - 0.5);
        // Rings share the cards in proportion to their circumference.
        const ringCount = clamp(Math.round(rings), 1, 8);
        const weights = Array.from({ length: ringCount }, (_, i) => i + 1.6);
        const total = weights.reduce((a, b) => a + b, 0);
        const ringSizes = new Int32Array(ringCount);
        let assigned = 0;
        for (let i = 0; i < ringCount; i++) {
          ringSizes[i] = i === ringCount - 1 ? cards - assigned : Math.max(1, Math.round((cards * weights[i]) / total));
          assigned += ringSizes[i];
        }
        const ringOf = new Int32Array(cards);
        const slotOf = new Int32Array(cards);
        let k = 0;
        for (let ring = 0; ring < ringCount; ring++) {
          for (let slot = 0; slot < ringSizes[ring] && k < cards; slot++, k++) {
            ringOf[k] = ring;
            slotOf[k] = slot;
          }
        }
        return { order, field, delays, gather, ringCount, ringSizes, ringOf, slotOf };
      }, [seed, cards, items.length, rings]);
    
      const setFormation = useCallback(
        (next: Formation | number) => {
          const index = typeof next === "number" ? wrap(next, list.length) : list.indexOf(next);
          if (index < 0 || index === formationIndex) return;
          setFormationIndex(index);
          onFormationChange?.(list[index], index);
        },
        [list, formationIndex, onFormationChange],
      );
    
      const openCard = useCallback(
        (next: OpenState) => {
          if (next) {
            const item = items[next.item];
            if (!item) return;
            setOpen(next);
            onOpen?.(item, next.item);
            onSelect?.(item, next.item);
          } else if (open) {
            setOpen(null);
            onOpen?.(null, open.item);
          }
        },
        [items, open, onOpen, onSelect],
      );
      const openItem = (item: number) => {
        const card = plan.order.indexOf(item);
        openCard({ card: card >= 0 ? card : 0, item });
      };
    
      const { hostRef, bind, status, input, rendererRef } = useGallery({
        images: items.map((item) => item.src),
        count: items.length,
        loop: true,
        startIndex,
        draggable,
        wheel: false,
        pauseOnHover,
        pausable: true,
        paused,
        dpr,
        trackPointer: true,
        dragDelta: (move) => {
          const state = drag.current;
          const now = performance.now();
          const elapsed = Math.max(0.008, (now - (state.last || now - 16)) / 1000);
          state.last = now;
          if (formation === "field") {
            state.panX += move.dx;
            state.panY -= move.dy;
            state.panVX = clamp(move.dx / elapsed, -2400, 2400);
            state.panVY = clamp(-move.dy / elapsed, -2400, 2400);
          } else if (formation === "globe") {
            const turn = move.dx / Math.max(120, Math.min(move.width, move.height) * 0.45);
            state.angle += turn;
            state.angleV = clamp(turn / elapsed, -8, 8);
          } else {
            // Rings and spiral turn with the pointer's angle around the center.
            const cx = move.width / 2;
            const cy = move.height / 2;
            const before = Math.atan2(-(move.y - move.dy - cy), move.x - move.dx - cx);
            const after = Math.atan2(-(move.y - cy), move.x - cx);
            let turn = after - before;
            if (turn > Math.PI) turn -= Math.PI * 2;
            if (turn < -Math.PI) turn += Math.PI * 2;
            state.angle += turn;
            state.angleV = clamp(turn / elapsed, -8, 8);
          }
          return 0;
        },
        onIndexChange,
        onSelect: (item) => openItem(item),
        onEscape: () => openCard(null),
        onTap: (point) => {
          if (open) return openCard(null);
          if (!openOnClick) return;
          const hit = rendererRef.current?.pick(point.x, point.y);
          if (hit && hit.id >= 0) openCard({ card: hit.id, item: plan.order[hit.id] });
        },
        frame: ({ dt, time, width, height, reducedMotion, ambient, memory, renderer }, gallery) => {
          if (memory.ambient !== Number(ambient)) {
            memory.ambient = Number(ambient);
            gallery.hold("ambient", !ambient);
          }
          let moving = false;
          const state = drag.current;
          // Auto spin (slows on hover, stops when paused or open) plus drag inertia.
          let spinTarget = spin !== 0 && !paused && !gallery.userPaused && ambient && !reducedMotion && !open ? (spin * Math.PI) / 180 : 0;
          if (pauseOnHover && gallery.hovering()) spinTarget *= 0.3;
          memory.spinV = (memory.spinV ?? 0) + (spinTarget - (memory.spinV ?? 0)) * (1 - Math.exp(-dt * 2.5));
          if (spinTarget === 0 && Math.abs(memory.spinV) < 0.0005) memory.spinV = 0;
          memory.auto = (memory.auto ?? 0) + memory.spinV * dt;
          if (!gallery.dragging()) {
            if (state.angleV || state.panVX || state.panVY) moving = true;
            state.angle += state.angleV * dt;
            state.panX += state.panVX * dt;
            state.panY += state.panVY * dt;
            const decay = Math.exp(-dt / 0.6);
            state.angleV = Math.abs(state.angleV * decay) < 0.002 ? 0 : state.angleV * decay;
            state.panVX = Math.abs(state.panVX * decay) < 1 ? 0 : state.panVX * decay;
            state.panVY = Math.abs(state.panVY * decay) < 1 ? 0 : state.panVY * decay;
          }
          const fieldDrifts = formation === "field" && !paused && !gallery.userPaused && !reducedMotion && ambient;
          if (memory.spinV || fieldDrifts) moving = true;
          if (fieldDrifts) memory.drift = (memory.drift ?? 0) + dt;
          if (gallery.update(dt)) moving = true;
    
          // Group tilt toward the pointer, eased.
          const pointer = gallery.pointer();
          const tiltOn = tilt !== 0 && !reducedMotion && pointer.inside && !open;
          const goalX = tiltOn ? pointer.x / width - 0.5 : 0;
          const goalY = tiltOn ? pointer.y / height - 0.5 : 0;
          const ease = 1 - Math.exp(-dt * 4);
          memory.tiltX = (memory.tiltX ?? 0) + (goalX - (memory.tiltX ?? 0)) * ease;
          memory.tiltY = (memory.tiltY ?? 0) + (goalY - (memory.tiltY ?? 0)) * ease;
          if (Math.abs(goalX - memory.tiltX) + Math.abs(goalY - memory.tiltY) > 0.001) moving = true;
    
          // Formation changes start a staggered transition.
          if (memory.formation === undefined) memory.formation = formationIndex;
          if (memory.formation !== formationIndex) {
            memory.from = memory.formation;
            memory.formation = formationIndex;
            memory.t0 = time;
          }
          const transitioning = memory.t0 !== undefined && time - memory.t0 < MOVE + STAGGER && !reducedMotion;
          if (transitioning) moving = true;
    
          // Open card spring.
          const openTarget = open ? 1 : 0;
          if (open) memory.openCard = open.card;
          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;
          }
          const openT = smoothstep(0, 1, memory.openT);
          const openIndex = open ? open.card : memory.openT > 0.001 ? (memory.openCard ?? -1) : -1;
    
          // Hover: the card under the pointer holds still, grows and gets an accent ring.
          const hit = pointer.inside && !gallery.dragging() && openIndex < 0 ? renderer.pick(pointer.x, pointer.y) : null;
          const hovered = hit && hit.id >= 0 ? hit.id : -1;
          // `hold` is the card holding still (or easing back after the pointer left it).
          if (hovered >= 0 && hovered !== (memory.hold ?? -1)) {
            memory.hold = hovered;
            memory.holdSet = 0;
            memory.hoverT = 0;
          }
          const holdTarget = hovered >= 0 && hovered === memory.hold ? 1 : 0;
          memory.hoverT = (memory.hoverT ?? 0) + (holdTarget - (memory.hoverT ?? 0)) * (reducedMotion ? 1 : 1 - Math.exp(-dt * 14));
          if (holdTarget === 0 && memory.hoverT < 0.002) memory.hold = -1;
          if (Math.abs(holdTarget - memory.hoverT) > 0.002) moving = true;
    
          const minSide = Math.min(width, height);
          const size = cardSize > 0 ? cardSize : clamp(Math.sqrt((width * height * 0.34) / cards), 20, 96);
          const stage: Stage = {
            inner: clamp(clearance, 0, 0.8) * minSide * 0.5,
            outer: minSide * 0.5 - size * 0.55,
            stretch: clamp(width / height, 1, 1.7),
            width,
            height,
            angle: memory.auto + state.angle,
            panX: state.panX,
            panY: state.panY,
            time: memory.drift ?? 0,
            size,
            count: cards,
            ringCount: plan.ringCount,
            shape,
            ringOf: plan.ringOf,
            slotOf: plan.slotOf,
            ringSizes: plan.ringSizes,
            field: plan.field,
          };
          const current = list[formationIndex % list.length];
          const previous = list[wrap(memory.from ?? formationIndex, list.length)];
          const tiltRadX = ((tilt * Math.PI) / 180) * memory.tiltY * 2;
          const tiltRadY = ((tilt * Math.PI) / 180) * memory.tiltX * 2;
          const cosX = Math.cos(tiltRadX);
          const sinX = Math.sin(tiltRadX);
          const cosY = Math.cos(tiltRadY);
          const sinY = Math.sin(tiltRadY);
    
          const out: GalleryCard[] = [];
          for (let k = 0; k < cards; k++) {
            const to = spotFor(current, k, stage);
            let spot = to;
            if (transitioning) {
              const from = spotFor(previous, k, stage);
              const e = easeInOut(clamp((time - (memory.t0 ?? 0) - plan.delays[k]) / MOVE, 0, 1));
              let x: number;
              let y: number;
              if (transition === "gather") {
                // Cluster in the center, then burst out.
                const gx = plan.gather[k * 2] * size * 2.2;
                const gy = plan.gather[k * 2 + 1] * size * 2.2;
                const a = smoothstep(0, 0.5, e);
                const b = smoothstep(0.5, 1, e);
                x = e < 0.5 ? lerp(from.x, gx, a) : lerp(gx, to.x, b);
                y = e < 0.5 ? lerp(from.y, gy, a) : lerp(gy, to.y, b);
              } else if (transition === "swirl") {
                // Curve around the center: angle and radius interpolate separately, plus half a turn.
                const a0 = Math.atan2(from.y, from.x);
                const a1 = Math.atan2(to.y, to.x);
                const turn = wrap(a1 - a0, Math.PI * 2) + Math.PI;
                const r = lerp(Math.hypot(from.x, from.y), Math.hypot(to.x, to.y), e);
                x = Math.cos(a0 + turn * e) * r;
                y = Math.sin(a0 + turn * e) * r;
              } else {
                x = lerp(from.x, to.x, e);
                y = lerp(from.y, to.y, e);
              }
              spot = {
                x,
                y,
                z: lerp(from.z, to.z, e),
                round: lerp(from.round, to.round, e),
                opacity: lerp(from.opacity, to.opacity, e),
                billboard: e > 0.5 ? to.billboard : from.billboard,
              };
            }
            // Group tilt: rotate the formation around x, then y.
            const y1 = spot.y * cosX - spot.z * sinX;
            const z1 = spot.y * sinX + spot.z * cosX;
            const x2 = spot.x * cosY + z1 * sinY;
            const z2 = -spot.x * sinY + z1 * cosY;
            const card: GalleryCard = {
              image: plan.order[k],
              id: k,
              x: x2,
              y: y1,
              z: z2,
              rotateX: spot.billboard ? 0 : -tiltRadX,
              rotateY: spot.billboard ? 0 : tiltRadY,
              width: size,
              height: size,
              radius: cardRadius,
              round: spot.round,
              opacity: spot.opacity,
              shadow: clamp(shadow, 0, 1) * spot.opacity,
              dim: openT * 0.5,
            };
            if (k === memory.hold) {
              // Hold still: freeze where it was when the hover started.
              if (!memory.holdSet) {
                memory.holdSet = 1;
                memory.holdX = card.x;
                memory.holdY = card.y;
                memory.holdZ = card.z;
              }
              const t = memory.hoverT;
              card.x = lerp(card.x, memory.holdX ?? card.x, t);
              card.y = lerp(card.y, memory.holdY ?? card.y, t);
              card.z = lerp(card.z, (memory.holdZ ?? card.z) + 80, t);
              card.scale = lerp(1, hoverScale, t);
              card.ring = t;
              card.opacity = Math.max(card.opacity ?? 1, t);
              card.shadow = Math.max(card.shadow ?? 0, t);
            }
            if (k === openIndex) {
              const target = (minSide * 0.56) / size;
              if (open && memory.openFromSet !== 1) {
                memory.openFromSet = 1;
                memory.openX = card.x;
                memory.openY = card.y;
              }
              if (!open) memory.openFromSet = 0;
              card.x = lerp(memory.openX ?? card.x, 0, openT);
              card.y = lerp(memory.openY ?? card.y, height * 0.04, openT);
              card.z = 300 * openT + (card.z ?? 0) * (1 - openT);
              card.rotateX = lerp(card.rotateX ?? 0, 0, openT);
              card.rotateY = lerp(card.rotateY ?? 0, 0, openT);
              card.scale = lerp(card.scale ?? 1, target, openT);
              card.round = lerp(spot.round, 0, openT);
              card.radius = lerp(cardRadius, 14 / target, openT);
              card.opacity = Math.max(card.opacity ?? 1, openT);
              card.shadow = 1;
              card.dim = 0;
              card.ring = 0;
            }
            out.push(card);
          }
          return {
            cards: out,
            animating: moving,
            scene: { fov: 30, ring: accent.current, shadowOffset: 6, shadowBlur: 12, shadowOpacity: 0.22 },
          };
        },
      });
    
      // Cycle formations on a timer (paused by hover, drag, an open card, Space, hidden tabs).
      const { canAutoplay, hold, next, previous, goTo, userPaused } = input;
      useEffect(() => {
        hold("open", Boolean(open));
      }, [hold, open]);
      useEffect(() => {
        if (!autoplay || list.length < 2) return;
        const id = window.setInterval(() => {
          if (canAutoplay()) setFormation(formationIndex + 1);
        }, Math.max(1500, interval));
        return () => window.clearInterval(id);
      }, [autoplay, list.length, interval, canAutoplay, setFormation, formationIndex]);
    
      // Optional wheel spin (off by default so the page keeps scrolling over the gallery).
      useEffect(() => {
        const host = hostRef.current?.parentElement;
        if (!wheel || !host) return;
        const onWheel = (event: WheelEvent) => {
          event.preventDefault();
          const state = drag.current;
          if (formation === "field") state.panVY += event.deltaY * 2;
          else state.angleV = clamp(state.angleV + event.deltaY * 0.004, -8, 8);
        };
        host.addEventListener("wheel", onWheel, { passive: false });
        return () => host.removeEventListener("wheel", onWheel);
      }, [wheel, formation, hostRef]);
    
      useImperativeHandle(
        ref,
        () => ({ next, previous, goTo: (index: number) => goTo(index), setFormation, close: () => openCard(null) }),
        [next, previous, goTo, setFormation, openCard],
      );
    
      const fallback = status === "fallback";
      const openedItem = open ? items[open.item] : undefined;
      useEffect(() => {
        const host = hostRef.current;
        if (!host) return;
        const resolve = () => (accent.current = cssColorToVec3(accentColor, host));
        resolve();
        // A theme switch changes what the color resolves to.
        return observeTheme(resolve);
      }, [accentColor, hostRef]);
    
      return (
        <div
          ref={bind}
          role="group"
          aria-roledescription="carousel"
          aria-label={ariaLabel}
          tabIndex={-1}
          className={cn("relative size-full min-h-[420px] touch-pan-y overflow-hidden rounded-[inherit] outline-none select-none [container-type:size] 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} aspect="1 / 1" onSelect={onSelect} className="absolute inset-0" /> : null}
          </div>
          {children && !fallback ? (
            <div className={cn("pointer-events-none absolute inset-0 grid place-items-center transition-opacity duration-300 motion-reduce:transition-none", open ? "opacity-0" : "opacity-100")}>
              <div data-gallery-control inert={Boolean(open)} className={cn("max-w-[46cqmin] text-center", !open && "pointer-events-auto")}>
                {children}
              </div>
            </div>
          ) : null}
          {fallback ? null : (
            <GalleryA11y items={items} index={input.index} label={ariaLabel} live={!autoplay || paused || userPaused} onActivate={(i) => openItem(i)} />
          )}
          <AnimatePresence>
            {captions && openedItem && !fallback ? (
              <motion.div
                key={open?.item}
                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 photo"
                  onClick={() => openCard(null)}
                  className={cn("relative after:absolute after:-inset-1 grid size-8 shrink-0 cursor-pointer place-items-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring", scheme === "dark" ? "hover:bg-white/10" : "hover:bg-surface-2")}
                >
                  <X className="size-4" aria-hidden="true" />
                </button>
              </motion.div>
            ) : null}
          </AnimatePresence>
          {controls && list.length > 1 && !fallback ? (
            <div
              data-gallery-control
              role="group"
              aria-label="Formation"
              inert={Boolean(open)}
              className={cn(
                "absolute bottom-3 left-1/2 flex -translate-x-1/2 gap-0.5 rounded-full border p-0.5 shadow-sm backdrop-blur transition-opacity duration-200",
                open ? "opacity-0" : "opacity-100",
                scheme === "dark" ? "border-white/15 bg-zinc-900/70" : "border-border bg-background/85",
              )}
            >
              {list.map((name, i) => {
                const active = i === formationIndex;
                return (
                  <button
                    key={name}
                    type="button"
                    data-gallery-control
                    aria-pressed={active}
                    onClick={() => setFormation(i)}
                    className={cn(
                      "h-8 cursor-pointer rounded-full px-3 text-xs font-medium outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
                      active ? (scheme === "dark" ? "bg-white text-zinc-900" : "bg-foreground text-background") : scheme === "dark" ? "text-white/70 hover:text-white" : "text-muted-foreground hover:text-foreground",
                    )}
                  >
                    {LABELS[name]}
                  </button>
                );
              })}
            </div>
          ) : null}
        </div>
      );
    }

Usage

import { FormationGallery } from "@/components/ui/formation-gallery/formation-gallery";

<div className="h-[34rem]">
  <FormationGallery items={photos} count={96} transition="gather" onFormationChange={(name) => console.log(name)}>
    <h2 className="text-2xl font-semibold">Ninety-six prints, four formations</h2>
  </FormationGallery>
</div>

API reference

PropTypeDefaultDescription
items*GalleryItem[]–Photos on the cards; they repeat when there are more cards than items.
childrenReactNode–Headline or call to action kept in the empty center. It fades while a card is open.
countnumber96Number of cards, up to 160.
formationsFormation[]["rings", "spiral", "globe", "field"]Formations to cycle through, in order.
transitionFormationTransition"gather"How cards travel between formations.
startIndexnumber0Item the keyboard starts on.
autoplaybooleantrueMove to the next formation every `interval`.
intervalnumber6500Milliseconds per formation.
ringsnumber4Concentric rings in the "rings" formation.
cardSizenumber0Card size in px; 0 sizes cards to the stage and count.
clearancenumber0.36Empty center kept for children, as a fraction of the smaller side.
spinnumber8Rotation speed of rings, spiral and globe in degrees per second.
shape"auto" | "square" | "circle""auto"Card shape: "auto" uses rounded squares in rings and spiral, circles in globe and field.
cardRadiusnumber8Corner radius of square cards, px.
tiltnumber10Tilts the whole formation toward the pointer, in degrees; 0 turns it off.
hoverScalenumber1.6Scale of the card under the pointer.
shadownumber0.45Soft shadow strength, 0–1.
accentColorstring"var(--primary)"Ring around the hovered card (any CSS color, including a theme variable).
openOnClickbooleantrueClick a card to enlarge it in the center with its caption.
seednumber7Seed for the card order and the field layout.
pauseOnHoverbooleantrueHovering pauses autoplay and slows the spin.
draggablebooleantrueDrag spins rings, spiral and globe, or pans the field, with inertia.
wheelbooleanfalseThe wheel spins the formation (it then keeps the page from scrolling over the gallery).
captionsbooleantrueCaption and close button under an open card.
controlsbooleantrueFormation buttons along the bottom.
backgroundColorstring"transparent"Background color; text switches to light on dark colors.
pausedbooleanfalseStops spin, drift and autoplay (input still works).
dprnumber2Highest device pixel ratio rendered.
ariaLabelstring"Photos"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's active item changes.
onSelect(item: GalleryItem, index: number) => void–Fires when a card opens.
onFormationChange(formation: Formation, index: number) => void–Fires when the formation changes.
onOpen(item: GalleryItem | null, index: number) => void–Fires when a card opens (with its item) or closes (null).
refRef<FormationGalleryHandle>–Imperative `next()`, `previous()`, `goTo()`, `setFormation()` and `close()`.
  • Every formation is a function of the card index and a few live values (spin angle, pan, time): rings share cards by circumference and counter-rotate, the spiral uses the golden angle, the globe is a Fibonacci sphere and the field is a seeded scatter that drifts and wraps.
  • Transitions blend two live formations, so cards keep moving mid-flight. Each card starts up to 0.45 s late for an organic feel; gather clusters them in the center before bursting out, swirl interpolates angle and radius around the center with an extra half turn, direct flies straight.
  • The clearance stays readable: cards fade out inside it, which also hides the front of the globe and field cards panned through the middle.
  • 160 cards plus their shadows are one instanced WebGL2 draw (320 instances). Cards morph between rounded squares and circles in the shader.
  • wheel is off by default because the formations have no ends, so a wheel handler would trap page scrolling. Turn it on for full-screen use.

Accessibility

  • The canvas is decorative (aria-hidden). A visually hidden list of buttons, one per photo, opens any photo enlarged with its caption; Escape or the close button puts it back.
  • The formation buttons report the current formation with aria-pressed. Space pauses spin, drift and the formation timer; hovering or opening a card pauses the timer too.
  • Your centered content stays real, focusable DOM; it becomes inert (and the formation buttons too) while a card is open.
  • With reduced motion nothing spins or drifts, formation changes and opening jump straight to the end, and the group tilt is off.
  • Without WebGL the photos render as a plain, swipeable scroll-snap list.