"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { animate, motion, useMotionValue, useReducedMotion, useTransform, type PanInfo } from "motion/react"; export type ImageStackItem = { src: string; alt: string }; type ImageStackProps = { /** Two or more pictures. The first is on top. */ images: ImageStackItem[]; /** Name read out by screen readers. */ label?: string; /** Set the size here. The cards fill this box. */ className?: string; }; type CardProps = { image: ImageStackItem; depth: number; count: number; index: number; flick: number; reduce: boolean; onThrow: (dir: number) => void; onLand: () => void; }; const TILT = [-3, 4, -5, 2, -4, 5]; const SETTLE = { type: "spring", stiffness: 260, damping: 26 } as const; function Card({ image, depth, count, index, flick, reduce, onThrow, onLand }: CardProps) { const ref = useRef(null); const x = useMotionValue(0); const turn = useTransform(x, [-300, 300], [-14, 14]); const top = depth === 0; useEffect(() => { const width = ref.current?.offsetWidth; if (!top || !flick || !width) return; let live = true; const out = animate(x, flick * width * 1.05, { duration: 0.24, ease: "easeOut" }); out.then(() => { if (!live) return; onLand(); animate(x, 0, SETTLE); }); return () => { live = false; out.stop(); }; }, [top, flick, x, onLand]); const release = (_: unknown, info: PanInfo) => { const width = ref.current?.offsetWidth ?? 300; if (Math.abs(info.offset.x) < width * 0.3 && Math.abs(info.velocity.x) < 500) { animate(x, 0, SETTLE); return; } if (reduce) x.set(0); onThrow(info.offset.x < 0 ? -1 : 1); }; return ( Math.abs(x.get()) < 4 && onThrow(1) : undefined} style={{ x, rotate: turn }} className={`size-full touch-pan-y overflow-hidden rounded-2xl shadow-xl shadow-black/40 ${top ? "cursor-grab active:cursor-grabbing" : ""}`} > {/* eslint-disable-next-line @next/next/no-img-element */} {top ); } /* ImageStack: a loose pile of pictures. Throw the top one aside, click it, or use the arrow keys, and it slides back under the pile. */ export function ImageStack({ images, label = "Stack of pictures", className = "aspect-[4/5] w-64" }: ImageStackProps) { const reduce = useReducedMotion() === true; const [order, setOrder] = useState(() => images.map((_, i) => i)); const [flick, setFlick] = useState(0); const land = useCallback(() => { setOrder(([first, ...rest]) => [...rest, first]); setFlick(0); }, []); const next = (dir: number) => { if (flick) return; if (reduce) land(); else setFlick(dir); }; const back = () => { if (flick) return; setOrder((now) => [now[now.length - 1], ...now.slice(0, -1)]); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight") next(1); else if (e.key === "ArrowLeft") back(); else return; e.preventDefault(); }; return (
{images.map((image, i) => ( ))} Picture {order[0] + 1} of {images.length}: {images[order[0]].alt}
); }