轮播
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>
);
}