Skip to content

Sleeve Rail

A fanned rail of album sleeves; the focused record slides out and spins.

Installation

CLI

npx shadcn@latest add https://hantui-theta.vercel.app/r/sleeve-rail.json

Manual

  1. Install the dependencies

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

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

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

    components/ui/sleeve-rail/sleeve-rail.tsx
    "use client";
    
    import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState, type CSSProperties, type PointerEvent, type Ref } from "react";
    import { ChevronLeft, ChevronRight, Pause, Play } from "lucide-react";
    import { cn } from "@/lib/utils";
    import { GalleryA11y } from "@/lib/gallery/gallery-a11y";
    import { GalleryCaption, chromeScheme } from "@/lib/gallery/gallery-chrome";
    import { GalleryFallback } from "@/lib/gallery/gallery-fallback";
    import { clamp, smoothstep } from "@/lib/gallery/math";
    import type { GalleryCard } from "@/lib/gallery/renderer";
    import type { GalleryHandle, GalleryItem } from "@/lib/gallery/types";
    import { useGallery } from "@/lib/gallery/use-gallery";
    
    export interface SleeveRailHandle extends GalleryHandle {
      /** Slides the focused sleeve's record out and spins it. */
      play: () => void;
      /** Slides the record back into its sleeve. */
      stop: () => void;
    }
    
    export interface SleeveRailProps {
      /** Albums: the image is the sleeve artwork (square works best), title and subtitle the album and artist. */
      items: GalleryItem[];
      /** Sleeve focused first. @default 0 */
      startIndex?: number;
      /** Step to the next sleeve every `interval` (cycles back to the first). Pauses while a record plays. @default false */
      autoplay?: boolean;
      /** Milliseconds between autoplay steps. @default 4000 */
      interval?: number;
      /** Pause autoplay while hovered. @default true */
      pauseOnHover?: boolean;
      /** Drag or throw the rail. @default true */
      draggable?: boolean;
      /** One sleeve per wheel notch. @default true */
      wheel?: boolean;
      /** Title and artist of the focused sleeve, sliding with the rail. @default true */
      captions?: boolean;
      /** The ruler under the rail: a tick per sleeve, a play button at its center and arrows at its ends. @default true */
      controls?: boolean;
      /** Tilt per step outward, like a hand of cards, in degrees. @default 5 */
      fanAngle?: number;
      /** How far neighbours turn away from the viewer, in degrees. @default 40 */
      turnAngle?: number;
      /** How far neighbours recede, px. @default 170 */
      depth?: number;
      /** Distance from the focused sleeve to its first neighbour, in sleeve widths. @default 0.62 */
      spread?: number;
      /** How much tighter the outer sleeves pack, 0–0.95. @default 0.6 */
      packing?: number;
      /** Sleeves visible on each side, 1–5. @default 3 */
      visibleSide?: number;
      /** Sleeve corner radius, px. @default 6 */
      radius?: number;
      /** How much outer sleeves darken, 0–1. @default 0.25 */
      dim?: number;
      /** Drop shadow strength, 0–1. @default 0.8 */
      shadow?: number;
      /** Show the vinyl record (peeks on hover, slides out to play). @default true */
      record?: boolean;
      /** Vinyl color. @default "#121212" */
      recordColor?: string;
      /** Spin speed while playing. @default 33.3 */
      rpm?: number;
      /** Play button and active tick color. "auto" picks the most vivid color of the focused artwork and adjusts it for contrast. @default "auto" */
      accentColor?: string;
      /** Background color; captions and controls switch to light text on dark colors. @default "transparent" */
      backgroundColor?: string;
      /** Stops autoplay and spinning (input still works). @default false */
      paused?: boolean;
      /** Highest device pixel ratio rendered. @default 2 */
      dpr?: number;
      /** Accessible name of the rail. @default "Albums" */
      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 focused sleeve changes. */
      onIndexChange?: (index: number) => void;
      /** Fires when the focused sleeve is clicked or Enter is pressed (the record starts playing). */
      onSelect?: (item: GalleryItem, index: number) => void;
      /** Fires when a record starts or stops playing. */
      onPlayChange?: (index: number, playing: boolean) => void;
      /** Imperative `next()`, `previous()`, `goTo(index)`, `play()` and `stop()`. */
      ref?: Ref<SleeveRailHandle>;
    }
    
    const TICK = 14;
    const RECORD_ID = -2;
    const DEFAULT_ACCENT = "var(--primary)";
    const toRad = (deg: number) => (deg * Math.PI) / 180;
    
    /** A vinyl record (grooves, track gaps, rim) drawn once to a data URL. */
    function vinylTexture(color: string) {
      const size = 512;
      const canvas = document.createElement("canvas");
      canvas.width = canvas.height = size;
      const ctx = canvas.getContext("2d");
      if (!ctx) return "";
      const r = size / 2;
      ctx.fillStyle = color;
      ctx.beginPath();
      ctx.arc(r, r, r - 1, 0, Math.PI * 2);
      ctx.fill();
      for (let g = r * 0.4; g < r * 0.965; g += 1.6) {
        const gap = (g > r * 0.62 && g < r * 0.635) || (g > r * 0.79 && g < r * 0.805);
        ctx.strokeStyle = gap ? "rgba(0,0,0,0.4)" : `rgba(255,255,255,${(0.025 + ((g * 7.13) % 1) * 0.035).toFixed(3)})`;
        ctx.lineWidth = gap ? 2 : 0.8;
        ctx.beginPath();
        ctx.arc(r, r, g, 0, Math.PI * 2);
        ctx.stroke();
      }
      ctx.strokeStyle = "rgba(255,255,255,0.1)";
      ctx.lineWidth = 2;
      ctx.beginPath();
      ctx.arc(r, r, r - 2, 0, Math.PI * 2);
      ctx.stroke();
      return canvas.toDataURL("image/png");
    }
    
    /** Two soft highlight lobes. Drawn over the record without spinning, so the light stays put. */
    function sheenTexture() {
      const size = 256;
      const canvas = document.createElement("canvas");
      canvas.width = canvas.height = size;
      const ctx = canvas.getContext("2d");
      if (!ctx || !("createConicGradient" in ctx)) return "";
      const gradient = ctx.createConicGradient(-Math.PI / 4, size / 2, size / 2);
      for (const [stop, alpha] of [[0, 0], [0.08, 0.2], [0.18, 0], [0.5, 0], [0.58, 0.14], [0.68, 0], [1, 0]] as const) gradient.addColorStop(stop, `rgba(255,255,255,${alpha})`);
      ctx.fillStyle = gradient;
      ctx.beginPath();
      ctx.arc(size / 2, size / 2, size / 2 - 1, 0, Math.PI * 2);
      ctx.fill();
      return canvas.toDataURL("image/png");
    }
    
    const luminance = ([r, g, b]: number[]) => {
      const [lr, lg, lb] = [r, g, b].map((c) => {
        const s = c / 255;
        return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
      });
      return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
    };
    const contrast = (a: number[], b: number[]) => {
      const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
      return (hi + 0.05) / (lo + 0.05);
    };
    
    const accentCache = new Map<string, string>();
    
    /** The most vivid color in an image, darkened or lightened until it reads at 3:1 against the background. */
    function vividAccent(image: HTMLImageElement, dark: boolean) {
      const size = 24;
      const canvas = document.createElement("canvas");
      canvas.width = canvas.height = size;
      const ctx = canvas.getContext("2d", { willReadFrequently: true });
      if (!ctx) return null;
      ctx.drawImage(image, 0, 0, size, size);
      const data = ctx.getImageData(0, 0, size, size).data;
      let best: number[] | null = null;
      let bestScore = -1;
      for (let i = 0; i < data.length; i += 4) {
        const r = data[i];
        const g = data[i + 1];
        const b = data[i + 2];
        const max = Math.max(r, g, b);
        const min = Math.min(r, g, b);
        const value = max / 255;
        const saturation = max ? (max - min) / max : 0;
        const score = saturation * (1 - Math.abs(value - 0.7));
        if (score > bestScore) {
          bestScore = score;
          best = [r, g, b];
        }
      }
      if (!best || bestScore < 0.12) return null;
      const against = dark ? [12, 12, 14] : [255, 255, 255];
      let color = best;
      for (let step = 0; step < 24 && contrast(color, against) < 3; step++) {
        color = color.map((c) => (dark ? c + (255 - c) * 0.12 : c * 0.88));
      }
      return `rgb(${color.map((c) => Math.round(c)).join(", ")})`;
    }
    
    /**
     * A fanned rail of album sleeves, each with a vinyl record tucked inside. The focused sleeve sits
     * in front; hover lifts its record, click or Enter slides it out to spin. Rendered with the shared
     * WebGL2 gallery engine; a hidden button list carries the accessible gallery.
     */
    export function SleeveRail({
      items,
      startIndex = 0,
      autoplay = false,
      interval = 4000,
      pauseOnHover = true,
      draggable = true,
      wheel = true,
      captions = true,
      controls = true,
      fanAngle = 5,
      turnAngle = 40,
      depth = 170,
      spread = 0.62,
      packing = 0.6,
      visibleSide = 3,
      radius = 6,
      dim = 0.25,
      shadow = 0.8,
      record = true,
      recordColor = "#121212",
      rpm = 33.3,
      accentColor = "auto",
      backgroundColor = "transparent",
      paused = false,
      dpr = 2,
      ariaLabel = "Albums",
      className,
      style,
      onIndexChange,
      onSelect,
      onPlayChange,
      ref,
    }: SleeveRailProps) {
      const count = items.length;
      const [playing, setPlaying] = useState(false);
      const [autoAccent, setAutoAccent] = useState<{ src: string; color: string } | null>(null);
      const ticks = useRef<HTMLDivElement>(null);
      const scrubbing = useRef<{ id: number; x: number; moved: boolean } | null>(null);
      const scheme = chromeScheme(backgroundColor);
    
      // Generated textures (client only; the server never renders the canvas).
      const vinyl = useMemo(() => (record && typeof document !== "undefined" ? vinylTexture(recordColor) : ""), [record, recordColor]);
      const sheen = useMemo(() => (record && typeof document !== "undefined" ? sheenTexture() : ""), [record]);
      const images = useMemo(() => [...items.map((item) => item.src), vinyl, sheen], [items, vinyl, sheen]);
      const vinylIndex = count;
      const sheenIndex = count + 1;
      const chromeHeight = (captions ? 44 : 0) + (controls ? 52 : 0);
    
      const setPlay = useCallback(
        (next: boolean, index: number) => {
          if (next === playing) return;
          setPlaying(next);
          onPlayChange?.(index, next);
        },
        [playing, onPlayChange],
      );
    
      const { hostRef, bind, status, input, rendererRef } = useGallery({
        images,
        count,
        startIndex,
        draggable,
        wheel,
        autoplay,
        interval,
        pauseOnHover,
        paused,
        dpr,
        trackPointer: record,
        dragDistance: (width, height) => Math.min((height - chromeHeight) * (record ? 0.46 : 0.62), width * 0.34) * spread,
        onIndexChange: (index) => {
          if (playing) setPlay(false, input.index);
          onIndexChange?.(index);
        },
        onSelect: (index) => {
          if (!playing) onSelect?.(items[index], index);
          setPlay(!playing, index);
        },
        onEscape: () => setPlay(false, input.index),
        onTap: (point, gallery) => {
          const hit = rendererRef.current?.pick(point.x, point.y);
          if (!hit) return;
          const active = gallery.activeIndex();
          if (hit.id === RECORD_ID || hit.id === active) {
            if (!playing && hit.id === active) onSelect?.(items[active], active);
            setPlay(!playing, active);
          } else gallery.goTo(hit.id);
        },
        frame: ({ dt, width, height, reducedMotion, ambient, memory, renderer }, gallery) => {
          if (memory.ambient !== Number(ambient)) {
            memory.ambient = Number(ambient);
            gallery.hold("ambient", !ambient);
          }
          let moving = gallery.update(dt);
          const position = gallery.position();
          const active = gallery.activeIndex();
          // The ruler's ticks slide with the rail (direct style write: no React render per frame).
          if (ticks.current) ticks.current.style.transform = `translateX(${(-position * TICK).toFixed(2)}px)`;
    
          const size = Math.min(height * (record ? 0.46 : 0.62), width * 0.34);
          const baseY = record ? -size * 0.12 : 0;
          const fan = toRad(fanAngle);
          const turn = toRad(turnAngle);
          const first = size * spread;
          const packed = first * (1 - clamp(packing, 0, 0.95));
          const side = clamp(Math.round(visibleSide), 1, 5);
          const cards: GalleryCard[] = [];
          for (let i = 0; i < count; i++) {
            const offset = i - position;
            const t = Math.abs(offset);
            if (t > side + 1) continue;
            const sign = Math.sign(offset);
            const near = Math.min(t, 1);
            const outer = Math.min(t, side);
            const opacity = 1 - smoothstep(side - 0.3, side + 0.7, t);
            cards.push({
              image: i,
              id: i,
              x: sign * (t < 1 ? t * first : first + (t - 1) * packed),
              // A gentle arc, like cards fanned in a hand.
              y: baseY - outer * outer * size * 0.01 * (fanAngle / 5),
              z: -depth * (near + Math.max(0, t - 1) * 0.35),
              rotateY: -sign * near * turn,
              rotateZ: -sign * outer * fan,
              width: size,
              height: size,
              radius,
              opacity,
              dim: clamp(dim, 0, 1) * Math.min(1, t / side),
              shadow: clamp(shadow, 0, 1) * opacity,
            });
          }
    
          if (record) {
            // Hover over the focused sleeve lifts its record a little.
            const pointer = gallery.pointer();
            const hit = pointer.inside && !gallery.dragging() ? renderer.pick(pointer.x, pointer.y) : null;
            const hovered = hit !== null && (hit.id === active || hit.id === RECORD_ID);
            // The record belongs to one sleeve: when focus moves on, it tucks back in before following.
            if (memory.owner === undefined) memory.owner = active;
            let target = 0;
            if (memory.owner !== active) {
              if ((memory.offset ?? 0) < 0.02) memory.owner = active;
            } else target = playing ? 1 : hovered ? 0.17 : 0;
            const offset = memory.offset ?? 0;
            if (reducedMotion) {
              memory.offset = target;
              memory.offsetV = 0;
            } else {
              const omega = 13;
              memory.offsetV = (memory.offsetV ?? 0) + (-omega * omega * (offset - target) - 2 * omega * (memory.offsetV ?? 0)) * Math.min(dt, 1 / 30);
              memory.offset = offset + memory.offsetV * Math.min(dt, 1 / 30);
              if (Math.abs(memory.offset - target) < 0.001 && Math.abs(memory.offsetV) < 0.01) {
                memory.offset = target;
                memory.offsetV = 0;
              } else moving = true;
            }
            const spinning = playing && !paused && !reducedMotion && memory.offset > 0.9;
            const spinTarget = spinning ? (rpm / 60) * Math.PI * 2 : 0;
            memory.spinV = (memory.spinV ?? 0) + (spinTarget - (memory.spinV ?? 0)) * (1 - Math.exp(-dt * 3));
            if (!spinning && memory.spinV < 0.01) memory.spinV = 0;
            memory.spin = ((memory.spin ?? 0) + memory.spinV * dt) % (Math.PI * 2);
            if (memory.spinV) moving = true;
    
            const sleeve = cards.find((card) => card.id === memory.owner);
            const lift = memory.offset;
            if (sleeve && lift > 0.001) {
              // While playing, the sleeve dips to make room for the record above it.
              sleeve.y -= lift * size * 0.3;
              const rz = sleeve.rotateZ ?? 0;
              const distance = lift * size * 0.98;
              const cx = sleeve.x - Math.sin(rz) * distance;
              const cy = sleeve.y + Math.cos(rz) * distance;
              const disc = size * 0.96;
              const base = { id: RECORD_ID, x: cx, y: cy, rotateY: sleeve.rotateY, opacity: sleeve.opacity, round: 1 };
              cards.push(
                { ...base, image: vinylIndex, z: sleeve.z - 2, rotateZ: rz - memory.spin, width: disc, height: disc, shadow: 0.5 * (sleeve.opacity ?? 1) },
                { ...base, image: memory.owner, z: sleeve.z - 1.6, rotateZ: rz - memory.spin, width: disc * 0.36, height: disc * 0.36 },
                { ...base, image: -1, z: sleeve.z - 1.4, rotateZ: 0, width: disc * 0.035, height: disc * 0.035, fill: [0.92, 0.92, 0.93, 1] },
              );
              if (sheen && renderer.isLoaded(sheenIndex)) cards.push({ ...base, image: sheenIndex, z: sleeve.z - 1.2, rotateZ: rz, width: disc, height: disc });
            }
          }
    
          return {
            cards,
            animating: moving,
            scene: { fov: 32, shadowOffset: 12, shadowBlur: 20, shadowOpacity: 0.26, edgeFade: [0.06, 0] },
          };
        },
      });
    
      const { next, previous, goTo, hold, scrub, scrubEnd, activeIndex } = input;
      useEffect(() => {
        hold("playing", playing);
      }, [hold, playing]);
    
      useImperativeHandle(
        ref,
        () => ({
          next,
          previous,
          goTo: (index: number) => goTo(index),
          play: () => setPlay(true, activeIndex()),
          stop: () => setPlay(false, activeIndex()),
        }),
        [next, previous, goTo, activeIndex, setPlay],
      );
    
      // "auto" accent: the most vivid color of the focused artwork (cached per image).
      const focused = items[input.index];
      const autoSrc = focused?.src ?? "";
      const dark = scheme === "dark";
      useEffect(() => {
        if (accentColor !== "auto" || !autoSrc) return;
        const key = `${autoSrc}|${dark}`;
        let cancelled = false;
        const cached = accentCache.get(key);
        const image = new Image();
        image.crossOrigin = "anonymous";
        image.src = autoSrc;
        (cached ? Promise.resolve() : image.decode()).then(
          () => {
            const color = cached ?? vividAccent(image, dark) ?? DEFAULT_ACCENT;
            accentCache.set(key, color);
            if (!cancelled) setAutoAccent({ src: key, color });
          },
          () => {},
        );
        return () => {
          cancelled = true;
        };
      }, [accentColor, autoSrc, dark]);
      const accent = accentColor === "auto" ? (autoAccent?.src === `${autoSrc}|${dark}` ? autoAccent.color : (autoAccent?.color ?? DEFAULT_ACCENT)) : accentColor;
    
      const onRulerDown = (event: PointerEvent<HTMLDivElement>) => {
        event.currentTarget.setPointerCapture(event.pointerId);
        scrubbing.current = { id: event.pointerId, x: event.clientX, moved: false };
      };
      const onRulerMove = (event: PointerEvent<HTMLDivElement>) => {
        const drag = scrubbing.current;
        if (!drag || drag.id !== event.pointerId) return;
        const dx = event.clientX - drag.x;
        if (!drag.moved && Math.abs(dx) < 4) return;
        drag.moved = true;
        drag.x = event.clientX;
        scrub(-dx / TICK);
      };
      const onRulerUp = (event: PointerEvent<HTMLDivElement>) => {
        const drag = scrubbing.current;
        scrubbing.current = null;
        if (!drag || drag.id !== event.pointerId) return;
        if (drag.moved) return scrubEnd();
        // A click jumps to the tick under the pointer.
        const rect = event.currentTarget.getBoundingClientRect();
        goTo(activeIndex() + Math.round((event.clientX - rect.left - rect.width / 2) / TICK));
      };
    
      const fallback = status === "fallback";
      const muted = scheme === "dark" ? "bg-white/30" : "bg-foreground/25";
      const button = cn(
        "grid size-9 shrink-0 cursor-pointer place-items-center rounded-full outline-none transition-[background-color,transform] duration-150 active:scale-[0.94] focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none",
        scheme === "dark" ? "text-white hover:bg-white/10" : "text-foreground hover:bg-surface-2",
      );
    
      return (
        <div
          ref={bind}
          role="group"
          aria-roledescription="carousel"
          aria-label={ariaLabel}
          // Focusable by click (not by Tab) so Escape and arrow keys work after pointer use.
          tabIndex={-1}
          className={cn("relative outline-none flex size-full min-h-[420px] touch-pan-y flex-col rounded-[inherit] select-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", className)}
          style={{ backgroundColor, "--sleeve-accent": accent, ...style } as CSSProperties}
        >
          <div ref={hostRef} className="relative min-h-0 flex-1">
            {fallback ? <GalleryFallback items={items} label={ariaLabel} aspect="1 / 1" onSelect={onSelect} className="absolute inset-0" /> : null}
          </div>
          {fallback ? null : (
            <GalleryA11y
              items={items}
              index={input.index}
              label={ariaLabel}
              live={!autoplay || paused}
              onFocusItem={(i) => goTo(i)}
              onActivate={(i) => {
                if (i !== input.index) return goTo(i);
                if (!playing) onSelect?.(items[i], i);
                setPlay(!playing, i);
              }}
            />
          )}
          {captions && !fallback ? <GalleryCaption item={focused} index={input.index} count={count} scheme={scheme} className="mx-auto h-11 w-full max-w-xs shrink-0 text-center" /> : null}
          {controls && !fallback ? (
            <div data-gallery-control className="relative mx-auto flex h-[52px] w-full max-w-md shrink-0 items-center gap-1 px-3">
              <button type="button" data-gallery-control aria-label="Previous album" onClick={previous} disabled={input.index <= 0} className={cn(button, "disabled:pointer-events-none disabled:opacity-35")}>
                <ChevronLeft className="size-4" aria-hidden="true" />
              </button>
              <div
                aria-hidden="true"
                onPointerDown={onRulerDown}
                onPointerMove={onRulerMove}
                onPointerUp={onRulerUp}
                onPointerCancel={onRulerUp}
                className="relative h-full flex-1 cursor-ew-resize touch-none overflow-hidden [mask-image:linear-gradient(90deg,transparent,black_20%,black_80%,transparent)]"
              >
                <div ref={ticks} className="absolute inset-y-0 left-1/2">
                  {items.map((item, i) => (
                    <span
                      key={`${item.src}-${i}`}
                      className={cn(
                        // Ticks are 20px tall and scale down from the base (transform only).
                        "absolute bottom-3 h-5 w-px origin-bottom -translate-x-1/2 rounded-full transition-[scale,background-color] duration-200 motion-reduce:transition-none",
                        i === input.index ? (record ? "scale-y-0" : "scale-y-100 bg-[var(--sleeve-accent)]") : i % 5 === 0 ? cn("scale-y-80", muted) : cn("scale-y-50", muted),
                      )}
                      style={{ left: i * TICK }}
                    />
                  ))}
                </div>
              </div>
              {record ? (
                <button
                  type="button"
                  data-gallery-control
                  aria-label={`${playing ? "Stop" : "Play"} ${focused?.title ?? "album"}`}
                  aria-pressed={playing}
                  onClick={() => {
                    if (!playing) onSelect?.(items[input.index], input.index);
                    setPlay(!playing, input.index);
                  }}
                  className="absolute left-1/2 grid size-10 -translate-x-1/2 cursor-pointer place-items-center rounded-full bg-[var(--sleeve-accent)] text-white shadow-md transition-[transform,background-color] duration-200 outline-none active:scale-[0.94] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 motion-reduce:transition-none"
                >
                  {playing ? <Pause className="size-4 fill-current" aria-hidden="true" /> : <Play className="ml-0.5 size-4 fill-current" aria-hidden="true" />}
                </button>
              ) : null}
              <button type="button" data-gallery-control aria-label="Next album" onClick={next} disabled={input.index >= count - 1} className={cn(button, "disabled:pointer-events-none disabled:opacity-35")}>
                <ChevronRight className="size-4" aria-hidden="true" />
              </button>
            </div>
          ) : null}
        </div>
      );
    }

Usage

import { SleeveRail } from "@/components/ui/sleeve-rail/sleeve-rail";

const albums = [
  { src: "/covers/low-tide.webp", alt: "Blue gradient cover", title: "Low Tide", subtitle: "Mara Quell" },
  // ...
];

<div className="h-[34rem]">
  <SleeveRail items={albums} rpm={33.3} onPlayChange={(index, playing) => player.toggle(index, playing)} />
</div>

API reference

PropTypeDefaultDescription
items*GalleryItem[]–Albums: the image is the sleeve artwork (square works best), title and subtitle the album and artist.
startIndexnumber0Sleeve focused first.
autoplaybooleanfalseStep to the next sleeve every `interval` (cycles back to the first). Pauses while a record plays.
intervalnumber4000Milliseconds between autoplay steps.
pauseOnHoverbooleantruePause autoplay while hovered.
draggablebooleantrueDrag or throw the rail.
wheelbooleantrueOne sleeve per wheel notch.
captionsbooleantrueTitle and artist of the focused sleeve, sliding with the rail.
controlsbooleantrueThe ruler under the rail: a tick per sleeve, a play button at its center and arrows at its ends.
fanAnglenumber5Tilt per step outward, like a hand of cards, in degrees.
turnAnglenumber40How far neighbours turn away from the viewer, in degrees.
depthnumber170How far neighbours recede, px.
spreadnumber0.62Distance from the focused sleeve to its first neighbour, in sleeve widths.
packingnumber0.6How much tighter the outer sleeves pack, 0–0.95.
visibleSidenumber3Sleeves visible on each side, 1–5.
radiusnumber6Sleeve corner radius, px.
dimnumber0.25How much outer sleeves darken, 0–1.
shadownumber0.8Drop shadow strength, 0–1.
recordbooleantrueShow the vinyl record (peeks on hover, slides out to play).
recordColorstring"#121212"Vinyl color.
rpmnumber33.3Spin speed while playing.
accentColorstring"auto"Play button and active tick color. "auto" picks the most vivid color of the focused artwork and adjusts it for contrast.
backgroundColorstring"transparent"Background color; captions and controls switch to light text on dark colors.
pausedbooleanfalseStops autoplay and spinning (input still works).
dprnumber2Highest device pixel ratio rendered.
ariaLabelstring"Albums"Accessible name of the rail.
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 focused sleeve changes.
onSelect(item: GalleryItem, index: number) => void–Fires when the focused sleeve is clicked or Enter is pressed (the record starts playing).
onPlayChange(index: number, playing: boolean) => void–Fires when a record starts or stops playing.
refRef<SleeveRailHandle>–Imperative `next()`, `previous()`, `goTo(index)`, `play()` and `stop()`.
  • Neighbours fan like a hand of cards: each step out tilts by fanAngle, turns by turnAngle and recedes by depth, and sleeves past the first pack tighter by packing, coverflow-style.
  • The record is three textured discs drawn behind the focused sleeve: generated vinyl grooves, the sleeve's own artwork as the label, and a spindle hole. A light sheen rides on top without spinning. When focus moves on while a record plays, it tucks back into its sleeve before the next one can come out.
  • accentColor="auto" samples the focused artwork at 24×24, picks its most vivid color and darkens it (or lightens it on dark backgrounds) until it reaches 3:1 contrast. Results are cached per image. Remote images need CORS headers for sampling.
  • The ruler's ticks slide with the rail through a direct style write in the render loop, so React never re-renders mid-drag. Dragging the ruler scrubs with the same momentum and snapping as dragging the rail; clicking a tick jumps to it.

Accessibility

  • The canvas is decorative (aria-hidden). A visually hidden list of buttons, one per album, carries the rail: Tab reaches the focused album, ← → move, Home and End jump to the ends, Enter plays or stops its record, Escape stops it.
  • The play button names the album and reports its state with aria-pressed. The arrows are disabled at the ends.
  • A polite live region names the focused album after each move (quiet while autoplay runs). The ruler is a pointer shortcut and is hidden from assistive tech; every action it offers is also available from the keyboard.
  • With reduced motion there is no autoplay, moves jump straight to the album, and the record slides out without spinning.
  • Without WebGL the albums render as a plain, swipeable scroll-snap list.