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