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