"use client"; import { useEffect, useRef, useState, type ReactNode } from "react"; import { animate, motion, useMotionValue, useMotionValueEvent, useReducedMotion, useSpring, useTransform, useVelocity, } from "motion/react"; export type DragCarouselItem = { id: string; /** Read out by screen readers. */ label: string; content: ReactNode; }; type DragCarouselProps = { items: DragCarouselItem[]; /** Slide width in pixels. */ itemWidth?: number; /** Space between slides in pixels. */ gap?: number; accent?: string; /** Name read out by screen readers. */ label?: string; className?: string; }; const GLIDE = { type: "spring", stiffness: 260, damping: 30 } as const; const nearest = (value: number, stops: number[]) => stops.reduce((best, stop, i) => (Math.abs(stop + value) < Math.abs(stops[best] + value) ? i : best), 0); /* DragCarousel: throw the strip and it glides, leans with its speed, and lands on a slide. Works by mouse, touch, keys and dots. */ export function DragCarousel({ items, itemWidth = 260, gap = 16, accent = "#a78bfa", label = "Carousel", className = "" }: DragCarouselProps) { const reduce = useReducedMotion(); const box = useRef(null); const track = useRef(null); const flung = useRef(false); const current = useRef(0); const [stops, setStops] = useState([0]); const [index, setIndex] = useState(0); const x = useMotionValue(0); const speed = useSpring(useVelocity(x), { stiffness: 200, damping: 40 }); const lean = useTransform(speed, [-2500, 2500], [5, -5]); useEffect(() => { const measure = () => { if (!box.current || !track.current) return; const spare = Math.max(0, track.current.scrollWidth - box.current.clientWidth); const cells = Array.from(track.current.children) as HTMLElement[]; const next = [...new Set(cells.map((cell) => Math.min(cell.offsetLeft, spare)))]; const at = Math.min(current.current, next.length - 1); current.current = at; setStops(next); setIndex(at); x.jump(-next[at]); }; const observer = new ResizeObserver(measure); if (box.current) observer.observe(box.current); if (track.current) observer.observe(track.current); return () => observer.disconnect(); }, [x]); useMotionValueEvent(x, "change", (value) => { const at = nearest(value, stops); current.current = at; setIndex(at); }); const go = (to: number) => { const at = Math.max(0, Math.min(stops.length - 1, to)); animate(x, -stops[at], reduce ? { duration: 0 } : GLIDE); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "ArrowRight") go(index + 1); else if (e.key === "ArrowLeft") go(index - 1); else return; e.preventDefault(); }; return (
1 ? "x" : false} dragConstraints={{ left: -stops[stops.length - 1], right: 0 }} dragElastic={0.12} dragTransition={{ power: 0.25, timeConstant: 180, modifyTarget: (target) => -stops[nearest(target, stops)] }} onPointerDownCapture={() => { flung.current = false; }} onDragStart={() => { flung.current = true; }} onClickCapture={(e) => { if (!flung.current) return; e.preventDefault(); e.stopPropagation(); }} className="relative flex w-max cursor-grab select-none active:cursor-grabbing [&_img]:pointer-events-none" style={{ x, gap, skewX: reduce ? 0 : lean }} > {items.map((item, i) => (
  • { if (box.current) box.current.scrollLeft = 0; go(nearest(-e.currentTarget.offsetLeft, stops)); }} className="shrink-0" style={{ width: itemWidth }} > {item.content}
  • ))}
    {stops.length > 1 && (
    {stops.map((_, i) => ( ))}
    )}

    {`Stop ${index + 1} of ${stops.length}`}

    ); }