import { useCallback, useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, } from 'react'; import { prefersReducedMotion } from './utils.js'; export interface TiltGalleryItem { /** Stable id — React key + aria-controls. */ id: string; /** Body slot for the tile. */ content: ReactNode; /** Optional caption rendered under the tile. */ caption?: ReactNode; } export interface TiltGalleryProps { /** The tiles to render. */ items: TiltGalleryItem[]; /** Maximum tilt angle in degrees (each axis). Default 8. */ maxTilt?: number; /** Width of each tile in px. Default 280. */ tileWidth?: number; /** Height of each tile in px. Default 200. */ tileHeight?: number; /** Gap between tiles in px. Default 16. */ gap?: number; /** Bypass tilt + glare entirely — useful for tests / reduced-motion. */ disableMotion?: boolean; /** Accessible label for the gallery region. */ ariaLabel?: string; className?: string; style?: CSSProperties; } /** * `` — horizontally-scrolling gallery of ``-style * tiles. Each tile tilts toward the cursor on hover; the gallery itself * supports keyboard arrow scrolling and respects * `prefers-reduced-motion` for the tilt effect. * * Wave 13.D.5. Multi-card sibling of the existing `` — built * on the same cursor-tracking math but with a single React component * orchestrating the whole row. */ export function TiltGallery({ items, maxTilt = 8, tileWidth = 280, tileHeight = 200, gap = 16, disableMotion, ariaLabel = 'Tilt gallery', className, style, }: TiltGalleryProps) { const reduced = disableMotion ?? prefersReducedMotion(); const railRef = useRef(null); const baseId = useId(); const onArrow = useCallback( (e: ReactKeyboardEvent) => { const rail = railRef.current; if (!rail) return; if (e.key === 'ArrowRight') { e.preventDefault(); rail.scrollBy({ left: tileWidth + gap, behavior: 'smooth' }); } else if (e.key === 'ArrowLeft') { e.preventDefault(); rail.scrollBy({ left: -(tileWidth + gap), behavior: 'smooth' }); } }, [tileWidth, gap], ); return (
{items.map((item, i) => ( ))}
); } function Tile({ id, item, maxTilt, width, height, reduced, }: { id: string; item: TiltGalleryItem; maxTilt: number; width: number; height: number; reduced: boolean; }) { const ref = useRef(null); const [tilt, setTilt] = useState<{ rx: number; ry: number; gx: number; gy: number }>({ rx: 0, ry: 0, gx: 50, gy: 50, }); useEffect(() => { if (reduced) return; const el = ref.current; if (!el) return; const onMove = (e: PointerEvent) => { const rect = el.getBoundingClientRect(); const x = (e.clientX - rect.left) / rect.width; // 0..1 const y = (e.clientY - rect.top) / rect.height; const ry = (x - 0.5) * 2 * maxTilt; // -max..max const rx = -(y - 0.5) * 2 * maxTilt; setTilt({ rx, ry, gx: x * 100, gy: y * 100 }); }; const onLeave = () => setTilt({ rx: 0, ry: 0, gx: 50, gy: 50 }); el.addEventListener('pointermove', onMove); el.addEventListener('pointerleave', onLeave); return () => { el.removeEventListener('pointermove', onMove); el.removeEventListener('pointerleave', onLeave); }; }, [reduced, maxTilt]); return (
{item.content}
{!reduced && ( {item.caption && (
{item.caption}
)}
); }