Skip to content

Ring Reel

A turning wheel of cards that zooms into an arc.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/ring-reel.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/ring-reel/ring-reel.tsx
    "use client";
    
    import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type ReactNode, type Ref } from "react";
    import { cn } from "@/lib/utils";
    import { cssColorToVec3, observeTheme } from "@/lib/color";
    import { GalleryA11y } from "@/lib/gallery/gallery-a11y";
    import { GalleryCaption, GalleryControls, chromeScheme } from "@/lib/gallery/gallery-chrome";
    import { GalleryFallback } from "@/lib/gallery/gallery-fallback";
    import { clamp, 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 RingReelView = "ring" | "arc";
    
    export interface RingReelHandle extends GalleryHandle {
      /** Zooms between the whole wheel ("ring") and the top of it ("arc"). */
      setView: (view: RingReelView) => void;
    }
    
    export interface RingReelProps {
      /** Images on the wheel. They repeat if `count` is larger. */
      items: GalleryItem[];
      /** Content centered inside the ring, e.g. a headline and a call to action. It fades out in arc view. */
      children?: ReactNode;
      /** Cards on the wheel. @default the number of items, up to 16 */
      count?: number;
      /** Card at the top first. @default 0 */
      startIndex?: number;
      /** View on first render. @default "ring" */
      defaultView?: RingReelView;
      /** Ring view turning speed in degrees per second; 0 stops it. @default 6 */
      spin?: number;
      /** In arc view, step the next card to the apex every `interval`. @default true */
      autoplay?: boolean;
      /** Milliseconds between arc view steps. @default 3500 */
      interval?: number;
      /** Hovering brakes the spin and pauses stepping. @default true */
      pauseOnHover?: boolean;
      /** Drag turns the wheel around its center (ring) or sideways (arc), snapping to a card. @default true */
      draggable?: boolean;
      /** One card per wheel notch. @default true */
      wheel?: boolean;
      /** Fraction of the wheel visible in arc view; smaller zooms in further. @default 0.22 */
      arcSize?: number;
      /** Height of the apex card's center in arc view, as a fraction from the top. @default 0.42 */
      apex?: number;
      /** Card width ÷ height. @default 0.72 */
      aspect?: number;
      /** Card corner radius, px. @default 10 */
      radius?: number;
      /** Space between neighbouring cards, px. @default 8 */
      gap?: number;
      /** Lean cards along the wheel; false keeps them upright like Ferris wheel cabins. @default true */
      lean?: boolean;
      /** Cards turn over in a wave from the top when the view changes, and now and then in ring view. @default true */
      flipWave?: boolean;
      /** Color of the card backs shown by the flip wave (any CSS color, including a theme variable). @default "var(--primary)" */
      accentColor?: string;
      /** Clicking a card in ring view zooms into the arc with that card on top. @default true */
      zoomOnClick?: boolean;
      /** Title and subtitle of the apex card in arc view. @default true */
      captions?: boolean;
      /** Previous and next buttons with a counter in arc view. @default true */
      controls?: boolean;
      /** Background color; captions and controls switch to light text on dark colors. @default "transparent" */
      backgroundColor?: string;
      /** Stops the spin and autoplay (input still works). @default false */
      paused?: boolean;
      /** Highest device pixel ratio rendered. @default 2 */
      dpr?: number;
      /** Accessible name of the reel. @default "Gallery" */
      ariaLabel?: string;
      /** Classes for the root, which fills its parent (at least 420px tall). */
      className?: string;
      /** Inline styles for the root. */
      style?: CSSProperties;
      /** Fires when the card at the top changes, with its position on the wheel. */
      onIndexChange?: (index: number) => void;
      /** Fires when the card at the top changes, with its item. */
      onActiveChange?: (item: GalleryItem, index: number) => void;
      /** Fires on clicking the apex card in arc view, or Enter there. */
      onSelect?: (item: GalleryItem, index: number) => void;
      /** Fires when the view changes. */
      onViewChange?: (view: RingReelView) => void;
      /** Imperative `next()`, `previous()`, `goTo(index)` and `setView(view)`. */
      ref?: Ref<RingReelHandle>;
    }
    
    const easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
    
    /** Parses #rgb, #rrggbb or rgb() into 0–1 channels; anything else falls back to the default blue. */
    function toRgb(color: string): [number, number, number] {
      const hex = color.trim().match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i);
      if (hex) {
        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 rgb = color.match(/^rgba?\(([^)]+)\)$/i);
      if (rgb) {
        const [r, g, b] = rgb[1].split(/[\s,/]+/).map(parseFloat);
        if ([r, g, b].every(Number.isFinite)) return [r / 255, g / 255, b / 255];
      }
      return [0.145, 0.388, 0.922];
    }
    
    /** Wheel layout for a stage size: radius and card size so the ring fits with room to spare. */
    function layout(width: number, height: number, slots: number, aspect: number, gap: number) {
      const fit = Math.min(width, height) * 0.47;
      const ratio = clamp(aspect, 0.3, 2);
      const radius = (fit + gap / (2 * ratio)) / (1 + Math.PI / (slots * ratio));
      const step = (Math.PI * 2) / slots;
      const cardWidth = Math.max(8, radius * step - gap);
      return { radius, step, cardWidth, cardHeight: cardWidth / ratio };
    }
    
    /**
     * A slowly turning wheel of cards that zooms into an arc at the top. Rendered with the shared
     * WebGL2 gallery engine; a hidden button list carries the accessible gallery.
     */
    export function RingReel({
      items,
      children,
      count,
      startIndex = 0,
      defaultView = "ring",
      spin = 6,
      autoplay = true,
      interval = 3500,
      pauseOnHover = true,
      draggable = true,
      wheel = true,
      arcSize = 0.22,
      apex = 0.42,
      aspect = 0.72,
      radius = 10,
      gap = 8,
      lean = true,
      flipWave = true,
      accentColor = "var(--primary)",
      zoomOnClick = true,
      captions = true,
      controls = true,
      backgroundColor = "transparent",
      paused = false,
      dpr = 2,
      ariaLabel = "Gallery",
      className,
      style,
      onIndexChange,
      onActiveChange,
      onSelect,
      onViewChange,
      ref,
    }: RingReelProps) {
      const slots = Math.max(3, Math.round(count ?? Math.min(items.length, 16)));
      const wheelItems = useMemo(() => Array.from({ length: slots }, (_, i) => items[i % items.length]).filter(Boolean), [items, slots]);
      const [view, setViewState] = useState<RingReelView>(defaultView);
      // Card-back color; a theme variable is resolved against the gallery once it has mounted.
      const back = useRef(toRgb(accentColor));
      const scheme = chromeScheme(backgroundColor);
      const itemIndex = (slot: number) => wrap(slot, items.length || 1);
    
      const setView = useCallback(
        (next: RingReelView) => {
          if (next === view) return;
          setViewState(next);
          onViewChange?.(next);
        },
        [view, onViewChange],
      );
    
      const select = (slot: number) => onSelect?.(items[itemIndex(slot)], itemIndex(slot));
    
      const { hostRef, bind, status, input, rendererRef } = useGallery({
        images: items.map((item) => item.src),
        count: slots,
        loop: true,
        startIndex,
        draggable,
        wheel,
        // Stepping only happens in arc view; the ring turns by drift instead.
        autoplay: autoplay && view === "arc",
        pausable: spin !== 0,
        interval,
        pauseOnHover,
        paused,
        dpr,
        dragDelta: (move) => {
          const { radius: r, step, cardWidth } = layout(move.width, move.height, slots, aspect, gap);
          if (view === "arc") {
            const arc = clamp(arcSize, 0.05, 0.6) * Math.PI * 2;
            const zoom = clamp((0.95 * move.width) / (2 * r * Math.sin(arc / 2)), 1, 8);
            return -move.dx / Math.max(1, (cardWidth + gap) * zoom);
          }
          // Ring view: the change in the pointer's angle around the wheel center turns the wheel.
          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 delta = after - before;
          if (delta > Math.PI) delta -= Math.PI * 2;
          if (delta < -Math.PI) delta += Math.PI * 2;
          return delta / step;
        },
        onIndexChange: (slot) => {
          onIndexChange?.(slot);
          const item = items[itemIndex(slot)];
          if (item) onActiveChange?.(item, itemIndex(slot));
        },
        onSelect: (slot) => (view === "ring" ? setView("arc") : select(slot)),
        onEscape: () => setView("ring"),
        onTap: (point, gallery) => {
          const hit = rendererRef.current?.pick(point.x, point.y);
          if (!hit) {
            if (view === "arc") setView("ring");
            return;
          }
          if (view === "ring") {
            if (!zoomOnClick) return;
            gallery.goTo(hit.id);
            setView("arc");
          } else if (hit.id === gallery.activeIndex()) select(hit.id);
          else gallery.goTo(hit.id);
        },
        frame: ({ dt, time, width, height, reducedMotion, ambient, memory }, gallery) => {
          if (memory.ambient !== Number(ambient)) {
            memory.ambient = Number(ambient);
            gallery.hold("ambient", !ambient);
          }
          let moving = false;
          // Zoom between ring (0) and arc (1) on a critically damped spring.
          const target = view === "arc" ? 1 : 0;
          memory.zoom ??= target;
          if (reducedMotion) {
            memory.zoom = target;
            memory.zoomV = 0;
          } else {
            const h = Math.min(dt, 1 / 30);
            memory.zoomV = (memory.zoomV ?? 0) + (-64 * (memory.zoom - target) - 16 * (memory.zoomV ?? 0)) * h;
            memory.zoom += memory.zoomV * h;
            if (Math.abs(memory.zoom - target) < 0.0005 && Math.abs(memory.zoomV) < 0.005) {
              memory.zoom = target;
              memory.zoomV = 0;
            } else moving = true;
          }
    
          // Ring view turns continuously; hover or a drag brakes it.
          const { radius: r, step, cardWidth, cardHeight } = layout(width, height, slots, aspect, gap);
          const drifting = view === "ring" && spin !== 0 && !paused && ambient && !reducedMotion && gallery.canAutoplay();
          const speed = spin / (360 / slots);
          memory.drift = (memory.drift ?? 0) + ((drifting ? speed : 0) - (memory.drift ?? 0)) * (1 - Math.exp(-dt * 2));
          if (!drifting && Math.abs(memory.drift) < 0.001) memory.drift = 0;
          if (memory.drift) {
            gallery.drift(memory.drift * dt);
            moving = true;
          }
          if (gallery.update(dt)) moving = true;
    
          // Flip wave: on view changes, and every 8–13 s while the ring turns.
          const viewCode = target;
          if (memory.view === undefined) memory.view = viewCode;
          if (memory.wave === undefined) memory.wave = -1;
          if (memory.view !== viewCode) {
            memory.view = viewCode;
            if (flipWave && !reducedMotion) memory.wave = time;
          }
          if (flipWave && drifting) {
            memory.nextWave ??= time + 6 + Math.random() * 4;
            if (time > memory.nextWave) {
              memory.wave = time;
              memory.nextWave = time + 8 + Math.random() * 5;
            }
          }
    
          const zoomT = smoothstep(0, 1, memory.zoom);
          const arc = clamp(arcSize, 0.05, 0.6) * Math.PI * 2;
          const arcZoom = clamp((0.95 * width) / (2 * r * Math.sin(arc / 2)), 1, 8);
          const zoom = arcZoom ** zoomT;
          const fov = 30;
          const distance = height / 2 / Math.tan((fov * Math.PI) / 360);
          const lookY = zoomT * (r - (0.5 - clamp(apex, 0.1, 0.9)) * (height / zoom));
    
          const position = gallery.position();
          const cards: GalleryCard[] = [];
          let waving = false;
          for (let slot = 0; slot < slots; slot++) {
            const rel = wrap(slot - position + slots / 2, slots) - slots / 2;
            const theta = Math.PI / 2 - rel * step;
            let flip = 0;
            if (memory.wave >= 0) {
              const f = clamp((time - memory.wave - Math.abs(rel) * 0.07) / 0.8, 0, 1);
              if (f < 1) waving = true;
              flip = f > 0 && f < 1 ? Math.PI * 2 * easeInOut(f) : 0;
            }
            cards.push({
              image: itemIndex(slot),
              id: slot,
              x: r * Math.cos(theta),
              y: r * Math.sin(theta),
              z: 0,
              rotateZ: lean ? theta - Math.PI / 2 : 0,
              flip,
              width: cardWidth,
              height: cardHeight,
              radius: radius,
              shadow: 0.55,
              dim: zoomT * 0.35 * smoothstep(0.6, 3, Math.abs(rel)),
              fill: Math.cos(flip) < 0 ? [...back.current, 1] : undefined,
            });
          }
          if (waving) moving = true;
          else memory.wave = -1;
    
          return {
            cards,
            animating: moving,
            scene: {
              fov,
              camera: { y: lookY, targetY: lookY, z: distance / zoom - distance },
              shadowOffset: 6 + zoomT * 8,
              shadowBlur: 12 + zoomT * 10,
              shadowOpacity: 0.22,
              edgeFade: [0.12 * zoomT, 0],
            },
          };
        },
      });
    
      const { next, previous, goTo } = input;
      useImperativeHandle(ref, () => ({ next, previous, goTo: (index: number) => goTo(index), setView }), [next, previous, goTo, setView]);
    
      const fallback = status === "fallback";
      const arcView = view === "arc";
      const activeItem = wheelItems[input.index];
      useEffect(() => {
        const host = hostRef.current;
        if (!host) return;
        const resolve = () => (back.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}
          // Focusable by click (not by Tab) so Escape and arrow keys work after pointer use.
          tabIndex={-1}
          className={cn("relative outline-none size-full min-h-[420px] touch-pan-y rounded-[inherit] 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={String(aspect)} 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,scale] duration-300 ease-out motion-reduce:transition-none",
                arcView ? "scale-95 opacity-0" : "opacity-100",
              )}
            >
              {/* Sized to the smaller side of the stage so it stays inside the ring's inner circle. */}
              <div data-gallery-control inert={arcView} className={cn("max-w-[54cqmin] text-center", !arcView && "pointer-events-auto")}>
                {children}
              </div>
            </div>
          ) : null}
          {fallback ? null : (
            <GalleryA11y
              items={wheelItems}
              index={input.index}
              label={ariaLabel}
              live={paused || (arcView ? !autoplay : spin === 0)}
              onFocusItem={(slot) => goTo(slot)}
              onActivate={(slot) => {
                if (!arcView) {
                  goTo(slot);
                  setView("arc");
                } else if (slot === input.index) select(slot);
                else goTo(slot);
              }}
            />
          )}
          {(captions || controls) && !fallback ? (
            <div
              inert={!arcView}
              className={cn(
                "absolute inset-x-0 bottom-0 flex h-14 items-center justify-between gap-3 px-4 transition-opacity duration-300 motion-reduce:transition-none",
                arcView ? "opacity-100" : "pointer-events-none opacity-0",
              )}
            >
              {captions ? <GalleryCaption item={activeItem} index={input.index} count={slots} scheme={scheme} className="flex-1" /> : <span />}
              {controls ? <GalleryControls index={input.index} count={slots} onPrevious={previous} onNext={next} scheme={scheme} /> : null}
            </div>
          ) : null}
        </div>
      );
    }

Usage

import { RingReel } from "@/components/ui/ring-reel/ring-reel";

<div className="h-[32rem]">
  <RingReel items={prints} onViewChange={(view) => console.log(view)}>
    <h2 className="text-2xl font-semibold">Sixteen prints on one turning wheel</h2>
  </RingReel>
</div>

API reference

PropTypeDefaultDescription
items*GalleryItem[]–Images on the wheel. They repeat if `count` is larger.
childrenReactNode–Content centered inside the ring, e.g. a headline and a call to action. It fades out in arc view.
countnumberthe number of items, up to 16Cards on the wheel.
startIndexnumber0Card at the top first.
defaultViewRingReelView"ring"View on first render.
spinnumber6Ring view turning speed in degrees per second; 0 stops it.
autoplaybooleantrueIn arc view, step the next card to the apex every `interval`.
intervalnumber3500Milliseconds between arc view steps.
pauseOnHoverbooleantrueHovering brakes the spin and pauses stepping.
draggablebooleantrueDrag turns the wheel around its center (ring) or sideways (arc), snapping to a card.
wheelbooleantrueOne card per wheel notch.
arcSizenumber0.22Fraction of the wheel visible in arc view; smaller zooms in further.
apexnumber0.42Height of the apex card's center in arc view, as a fraction from the top.
aspectnumber0.72Card width ÷ height.
radiusnumber10Card corner radius, px.
gapnumber8Space between neighbouring cards, px.
leanbooleantrueLean cards along the wheel; false keeps them upright like Ferris wheel cabins.
flipWavebooleantrueCards turn over in a wave from the top when the view changes, and now and then in ring view.
accentColorstring"var(--primary)"Color of the card backs shown by the flip wave (any CSS color, including a theme variable).
zoomOnClickbooleantrueClicking a card in ring view zooms into the arc with that card on top.
captionsbooleantrueTitle and subtitle of the apex card in arc view.
controlsbooleantruePrevious and next buttons with a counter in arc view.
backgroundColorstring"transparent"Background color; captions and controls switch to light text on dark colors.
pausedbooleanfalseStops the spin and autoplay (input still works).
dprnumber2Highest device pixel ratio rendered.
ariaLabelstring"Gallery"Accessible name of the reel.
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 card at the top changes, with its position on the wheel.
onActiveChange(item: GalleryItem, index: number) => void–Fires when the card at the top changes, with its item.
onSelect(item: GalleryItem, index: number) => void–Fires on clicking the apex card in arc view, or Enter there.
onViewChange(view: RingReelView) => void–Fires when the view changes.
refRef<RingReelHandle>–Imperative `next()`, `previous()`, `goTo(index)` and `setView(view)`.
  • The wheel's radius and card size are solved together so the ring plus its cards fits the stage; count sets how many cards share the wheel (items repeat if needed).
  • Arc view is a camera move, not a new layout: the camera zooms into the top of the same wheel until arcSize of it spans the width, and apex sets where the top card lands. The zoom runs on a critically damped spring.
  • The flip wave turns each card around its own axis (also when it leans along the wheel), starting at the top and running outward, and shows accentColor on the back. It plays on every view change and every 8–13 seconds while the ring turns.
  • In ring view a drag follows the pointer's angle around the wheel's center; in arc view it moves sideways. Both snap to a card. Vertical swipes still scroll the page on touch screens.

Accessibility

  • The canvas is decorative (aria-hidden). A visually hidden list of buttons, one per card, carries the reel: Tab reaches the card at the top, ← → turn the wheel, Enter zooms into the arc (or selects there), Escape zooms back out.
  • The arc view's caption and controls are inert while the ring is showing, and the centered content is inert in arc view, so hidden controls never take focus.
  • A polite live region names the card at the top after manual moves, and stays quiet while the wheel spins or autoplay steps.
  • With reduced motion the wheel stands still, the zoom and every step jump straight to the end, and the flip wave is off.
  • Without WebGL the cards render as a plain, swipeable scroll-snap list.