跳到主要内容
Example+

轮播

ui

src

TSX
// npm install motion
"use client";

import { useLayoutEffect, useRef, useState } from "react";
import { animate, motion, useMotionValue, useMotionValueEvent } from "motion/react";

const slides = [
    "https://cdn.pixabay.com/photo/2024/01/25/12/30/mountain-8531778_1280.jpg",
    "https://cdn.pixabay.com/photo/2025/06/28/17/56/forest-9685700_1280.jpg",
    "https://cdn.pixabay.com/photo/2025/05/30/17/15/mountain-9631829_1280.jpg",
    "https://cdn.pixabay.com/photo/2020/03/03/20/31/boat-4899802_1280.jpg",
    "https://cdn.pixabay.com/photo/2025/05/31/20/23/trees-9634157_1280.jpg",
];

/* 首尾各克隆一份:拖到边界时瞬间折回中间那份,图片相同所以看不出跳变 */
const LOOP = [...slides, ...slides, ...slides];
const LEN = slides.length;

export default function Page() {
    const track = useRef<HTMLDivElement>(null);
    const x = useMotionValue(0);
    const [index, setIndex] = useState(0);
    const indexRef = useRef(0);
    /* 吸附距离=单张图的实际宽度。量图片而不是量取景框:
       取景框若带边框,clientWidth 会比图片窄,误差累积就会错位 */
    const step = useRef(0);

    useLayoutEffect(() => {
        const first = track.current?.firstElementChild;
        if (!first) return;
        const measure = () => {
            const next = first.getBoundingClientRect().width;
            if (!next) return;
            step.current = next;
            x.set(-(LEN + indexRef.current) * next);
        };
        measure();
        const ro = new ResizeObserver(measure);
        ro.observe(first);
        return () => ro.disconnect();
    }, [x]);

    useMotionValueEvent(x, "change", (v) => {
        const i = ((Math.round(-v / step.current) % LEN) + LEN) % LEN;
        indexRef.current = i;
        setIndex(i);
    });

    /* 一次拖动最多翻一张:记下起点,超出一张的部分只做橡皮筋 */
    const origin = useRef(0);
    const clampToOneSlide = (target: number) => {
        const s = step.current;
        const base = Math.round(origin.current / s) * s;
        return Math.max(base - s, Math.min(base + s, target));
    };

    /* 落位后折回中间那一份,于是往哪边拖都永远有下一张 */
    const recenter = () => {
        const s = step.current;
        const i = Math.round(-x.get() / s);
        x.set(-(LEN + (((i % LEN) + LEN) % LEN)) * s);
    };

    return (
        <div className="flex min-h-screen flex-col items-center justify-center gap-9">
            <div className="w-full max-w-[620px] overflow-hidden">
                <motion.div
                    ref={track}
                    drag="x"
                    style={{ x }}
                    dragMomentum={false}
                    onDragStart={() => {
                        origin.current = x.get();
                    }}
                    onDrag={(_, info) => {
                        const raw = origin.current + info.offset.x;
                        const clamped = clampToOneSlide(raw);
                        /* 拖过一张之后阻尼掉 88%,手感上「拽不动了」 */
                        x.set(clamped + (raw - clamped) * 0.12);
                    }}
                    onDragEnd={(_, info) => {
                        const s = step.current;
                        /* 位移过三分之一、或者甩得够快,就翻过去;否则弹回 */
                        const flick = Math.abs(info.velocity.x) > 320;
                        const passed = Math.abs(info.offset.x) > s * 0.35;
                        const dir =
                            (Math.abs(info.offset.x) > 4 ? info.offset.x : info.velocity.x) < 0
                                ? -1
                                : 1;
                        const base = Math.round(origin.current / s) * s;
                        animate(x, flick || passed ? base + dir * s : base, {
                            type: "spring",
                            stiffness: 320,
                            damping: 38,
                            onComplete: recenter,
                        });
                    }}
                    className="flex w-full cursor-grab active:cursor-grabbing"
                >
                    {LOOP.map((src, i) => (
                        <img
                            key={i}
                            src={src}
                            alt=""
                            draggable={false}
                            className="h-[400px] w-full shrink-0 object-cover"
                        />
                    ))}
                </motion.div>
            </div>
            <div className="flex gap-4">
                {slides.map((_, i) => (
                    <span
                        key={i}
                        className={`size-3.5 transition-colors ${
                            i === index ? "bg-purple-700" : "bg-white/20"
                        }`}
                    />
                ))}
            </div>
        </div>
    );
}