3D Galleries & Carouselsv0.3.0
Helix Gallery
Cards wound on a vertical helix; scroll or drag to travel along it.
Installation
CLI
npx shadcn@latest add https://hantui-theta.vercel.app/r/helix-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/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/helix-gallery/helix-gallery.tsx"use client"; import { useImperativeHandle, type CSSProperties, type Ref } from "react"; import { cn } from "@/lib/utils"; import { GalleryA11y } from "@/lib/gallery/gallery-a11y"; import { GalleryCaption, GalleryControls, chromeScheme } from "@/lib/gallery/gallery-chrome"; import { GalleryFallback } from "@/lib/gallery/gallery-fallback"; import { clamp, smoothstep } from "@/lib/gallery/math"; import type { GalleryCard } from "@/lib/gallery/renderer"; import type { GalleryHandle, GalleryItem } from "@/lib/gallery/types"; import { useGallery } from "@/lib/gallery/use-gallery"; export interface HelixGalleryProps { /** Images wound on the helix, top to bottom. */ items: GalleryItem[]; /** Card facing the viewer first. @default 0 */ startIndex?: number; /** Travel to the next card every `interval`. @default false */ autoplay?: boolean; /** Milliseconds between autoplay steps. @default 3500 */ interval?: number; /** Pause autoplay while hovered. @default true */ pauseOnHover?: boolean; /** Drag up and down to travel along the helix. @default true */ draggable?: boolean; /** One card per wheel notch (the page scrolls on at either end). @default true */ wheel?: boolean; /** Title and subtitle of the card facing the viewer. @default true */ captions?: boolean; /** Previous and next buttons with a counter. @default true */ controls?: boolean; /** Cards per full turn of the helix. @default 8 */ perTurn?: number; /** Helix radius as a fraction of the stage width. @default 0.32 */ radius?: number; /** Vertical rise between neighbouring cards, as a fraction of the card height. @default 0.42 */ rise?: number; /** Card height as a fraction of the stage height. @default 0.42 */ size?: number; /** Card width ÷ height. @default 0.78 */ aspect?: number; /** Card corner radius, px. @default 12 */ cardRadius?: number; /** How much cards on the far side darken, 0–1. @default 0.55 */ dim?: number; /** Background color; captions and controls switch to light text on dark colors. @default "transparent" */ backgroundColor?: string; /** Stops autoplay (input still works). @default false */ paused?: boolean; /** Highest device pixel ratio rendered. @default 2 */ dpr?: number; /** Accessible name of the gallery. @default "Gallery" */ ariaLabel?: string; /** Classes for the root, which fills its parent (at least 420px tall). */ className?: string; /** Inline styles for the root. */ style?: CSSProperties; /** Fires when the card facing the viewer changes. */ onIndexChange?: (index: number) => void; /** Fires on clicking the front card or pressing Enter. */ onSelect?: (item: GalleryItem, index: number) => void; /** Imperative `next()`, `previous()` and `goTo(index)`. */ ref?: Ref<GalleryHandle>; } const CHROME = 56; /** * Cards wound on a vertical helix. Scrolling or dragging travels along it and the active card turns * to face the viewer. Rendered with the shared WebGL2 gallery engine. */ export function HelixGallery({ items, startIndex = 0, autoplay = false, interval = 3500, pauseOnHover = true, draggable = true, wheel = true, captions = true, controls = true, perTurn = 8, radius = 0.32, rise = 0.42, size = 0.42, aspect = 0.78, cardRadius = 12, dim = 0.55, backgroundColor = "transparent", paused = false, dpr = 2, ariaLabel = "Gallery", className, style, onIndexChange, onSelect, ref, }: HelixGalleryProps) { const count = items.length; const chrome = captions || controls ? CHROME : 0; const metrics = (width: number, height: number) => { const cardHeight = Math.min(clamp(size, 0.15, 0.8) * height, (width * 0.36) / clamp(aspect, 0.3, 3)); return { cardHeight, cardWidth: cardHeight * clamp(aspect, 0.3, 3), lift: cardHeight * clamp(rise, 0.1, 1.5), helix: clamp(radius, 0.1, 0.6) * width }; }; const { hostRef, bind, status, input, rendererRef } = useGallery({ images: items.map((item) => item.src), count, startIndex, draggable, wheel, autoplay, interval, pauseOnHover, paused, dpr, axis: "y", // Dragging up travels down the helix: one card per card rise. dragDistance: (width, height) => metrics(width, height - chrome).lift, onIndexChange, onSelect: (index) => onSelect?.(items[index], index), onTap: (point, gallery) => { const hit = rendererRef.current?.pick(point.x, point.y); if (!hit) return; if (hit.id === gallery.activeIndex()) onSelect?.(items[hit.id], hit.id); else gallery.goTo(hit.id); }, frame: ({ dt, width, height, ambient, memory }, gallery) => { if (memory.ambient !== Number(ambient)) { memory.ambient = Number(ambient); gallery.hold("ambient", !ambient); } const moving = gallery.update(dt); const position = gallery.position(); const { cardHeight, cardWidth, lift, helix } = metrics(width, height); const step = (Math.PI * 2) / clamp(Math.round(perTurn), 3, 24); const cards: GalleryCard[] = []; for (let i = 0; i < count; i++) { const offset = i - position; if (Math.abs(offset) > perTurn * 0.75) continue; const theta = offset * step; const facing = Math.cos(theta); cards.push({ image: i, id: i, x: Math.sin(theta) * helix, y: -offset * lift, z: (facing - 1) * helix, rotateY: theta, width: cardWidth, height: cardHeight, radius: cardRadius, dim: clamp(dim, 0, 1) * (1 - smoothstep(-0.2, 1, facing)), opacity: 1 - smoothstep(perTurn * 0.5, perTurn * 0.75, Math.abs(offset)), shadow: 0.5 * smoothstep(0, 1, facing), }); } return { cards, animating: moving, scene: { fov: 32, edgeFade: [0, 0.14], shadowOffset: 10, shadowBlur: 18, shadowOpacity: 0.22 } }; }, }); const { next, previous, goTo } = input; useImperativeHandle(ref, () => ({ next, previous, goTo: (index: number) => goTo(index) }), [next, previous, goTo]); const scheme = chromeScheme(backgroundColor); const fallback = status === "fallback"; return ( <div ref={bind} role="group" aria-roledescription="carousel" aria-label={ariaLabel} tabIndex={-1} className={cn("relative flex size-full min-h-[420px] touch-pan-x flex-col rounded-[inherit] outline-none select-none has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-ring", className)} style={{ backgroundColor, ...style }} > <div ref={hostRef} className="relative min-h-0 flex-1"> {fallback ? <GalleryFallback items={items} label={ariaLabel} aspect={String(aspect)} onSelect={onSelect} className="absolute inset-0" /> : null} </div> {fallback ? null : ( <GalleryA11y items={items} index={input.index} label={ariaLabel} live={!autoplay || paused} onFocusItem={(i) => goTo(i)} onActivate={(i) => (i === input.index ? onSelect?.(items[i], i) : goTo(i))} /> )} {chrome && !fallback ? ( <div className="flex shrink-0 items-center justify-between gap-3 px-4" style={{ height: CHROME }}> {captions ? <GalleryCaption item={items[input.index]} index={input.index} count={count} scheme={scheme} className="flex-1" /> : <span />} {controls ? <GalleryControls index={input.index} count={count} onPrevious={previous} onNext={next} scheme={scheme} bounded /> : null} </div> ) : null} </div> ); }
Usage
import { HelixGallery } from "@/components/ui/helix-gallery/helix-gallery";
<div className="h-[32rem]">
<HelixGallery items={photos} perTurn={8} onSelect={(item) => open(item)} />
</div>API reference
| Prop | Type | Default | Description |
|---|---|---|---|
items* | GalleryItem[] | – | Images wound on the helix, top to bottom. |
startIndex | number | 0 | Card facing the viewer first. |
autoplay | boolean | false | Travel to the next card every `interval`. |
interval | number | 3500 | Milliseconds between autoplay steps. |
pauseOnHover | boolean | true | Pause autoplay while hovered. |
draggable | boolean | true | Drag up and down to travel along the helix. |
wheel | boolean | true | One card per wheel notch (the page scrolls on at either end). |
captions | boolean | true | Title and subtitle of the card facing the viewer. |
controls | boolean | true | Previous and next buttons with a counter. |
perTurn | number | 8 | Cards per full turn of the helix. |
radius | number | 0.32 | Helix radius as a fraction of the stage width. |
rise | number | 0.42 | Vertical rise between neighbouring cards, as a fraction of the card height. |
size | number | 0.42 | Card height as a fraction of the stage height. |
aspect | number | 0.78 | Card width ÷ height. |
cardRadius | number | 12 | Card corner radius, px. |
dim | number | 0.55 | How much cards on the far side darken, 0–1. |
backgroundColor | string | "transparent" | Background color; captions and controls switch to light text on dark colors. |
paused | boolean | false | Stops autoplay (input still works). |
dpr | number | 2 | Highest device pixel ratio rendered. |
ariaLabel | string | "Gallery" | 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 card facing the viewer changes. |
onSelect | (item: GalleryItem, index: number) => void | – | Fires on clicking the front card or pressing Enter. |
ref | Ref<GalleryHandle> | – | Imperative `next()`, `previous()` and `goTo(index)`. |
- Card i sits at angle (i − position) × 2π / perTurn around a vertical axis and rises by rise × card height per card, turned so its face points outward. The card at the current position faces the viewer; cards on the far side darken and the column fades at the top and bottom.
- Vertical drags move one card per card rise and settle on a card; touch-action: pan-x keeps horizontal swipes free for the page.
Accessibility
- The canvas is decorative (aria-hidden). A visually hidden list of buttons carries the gallery: Tab reaches the card facing you, ↑ ↓ travel along the helix, Home and End jump to the ends, Enter selects.
- The wheel moves one card per notch and hands scrolling back to the page at either end, so the helix never traps the page.
- With reduced motion every move jumps straight to its card; there's no autoplay.
- Without WebGL the same images render as a plain, swipeable scroll-snap list.