"use client"; import { useRef, useState } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform, type MotionValue, type PanInfo } from "motion/react"; export type PerspectiveGalleryItem = { src: string; alt: string }; type PerspectiveGalleryProps = { /** Two or more pictures. The middle one starts in front. */ images: PerspectiveGalleryItem[]; /** How far the side pictures turn away, in degrees. */ tilt?: number; /** Name read out by screen readers. */ label?: string; /** Set the size here. The pictures fill its height. */ className?: string; }; type CardProps = { image: PerspectiveGalleryItem; index: number; front: boolean; place: MotionValue; tilt: number; onPick: (index: number) => void; }; const SETTLE = { type: "spring", stiffness: 180, damping: 24 } as const; const clamp = (v: number, low: number, high: number) => Math.max(low, Math.min(high, v)); function Card({ image, index, front, place, tilt, onPick }: CardProps) { const transform = useTransform(place, (p) => { const away = index - p; const side = clamp(away, -1, 1); return `translateX(${away * 34 + side * 44}%) translateZ(${-Math.abs(side) * 160 - Math.abs(away) * 40}px) rotateY(${-side * tilt}deg)`; }); const zIndex = useTransform(place, (p) => 100 - Math.round(Math.abs(index - p) * 10)); const opacity = useTransform(place, (p) => clamp(3.5 - Math.abs(index - p), 0, 1)); return ( onPick(index)} style={{ transform, zIndex, opacity }} aria-hidden={!front} className={`absolute inset-y-0 left-[27%] w-[46%] overflow-hidden rounded-2xl shadow-xl shadow-black/50 ${front ? "" : "cursor-pointer"}`} > {/* eslint-disable-next-line @next/next/no-img-element */} {front ); } /* PerspectiveGallery: a row of pictures in 3D. One faces you and the rest turn away. Drag, click a side picture, or use the arrow keys. */ export function PerspectiveGallery({ images, tilt = 52, label = "Picture gallery", className = "h-72 w-full" }: PerspectiveGalleryProps) { const reduce = useReducedMotion(); const last = images.length - 1; const [now, setNow] = useState(() => Math.floor(last / 2)); const place = useMotionValue(now); const box = useRef(null); const dragged = useRef(false); const go = (index: number) => { const to = clamp(index, 0, last); setNow(to); if (reduce) place.set(to); else animate(place, to, SETTLE); }; const pan = (_: unknown, info: PanInfo) => { const step = (box.current?.offsetWidth ?? 600) * 0.46; place.set(clamp(place.get() - info.delta.x / step, -0.3, last + 0.3)); }; const pick = (index: number) => { if (!dragged.current) go(index); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight") go(now + 1); else if (e.key === "ArrowLeft") go(now - 1); else return; e.preventDefault(); }; return ( (dragged.current = false)} onPanStart={() => { dragged.current = true; place.stop(); }} onPan={pan} onPanEnd={(_, info) => go(Math.round(place.get() - info.velocity.x / 1500))} style={{ perspective: 1000 }} className={`relative touch-pan-y select-none overflow-hidden outline-none focus-visible:ring-2 focus-visible:ring-white/70 ${className}`} > {images.map((image, i) => ( ))} Picture {now + 1} of {images.length}: {images[now].alt} ); }