滑动卡片堆
把最上面的卡片向两侧拖动,它会随手势倾斜;甩得够远就会飞出,下一张卡片随即上前。
01吉隆坡
用 GSAP 为 React 制作可滑动的卡片堆。渲染 4 张绝对定位、互相叠放的卡片;顶部卡片之后的每一层缩小 0.05 并下移 10px。在顶部卡片上 pointerdown 时捕获指针并跟随:x 为拖动距离,rotation 为 x * 0.08 度。松开时,如果 |x| > 110px,就在 0.4 秒内用 "power2.in" 把卡片补间到屏幕外 x = ±400、rotation ±25、opacity 0,然后把它移到卡片堆底部,并用 "power3.out" 让其余卡片上移一层。否则在 0.5 秒内用 "expo.out" 把它弹回 x 0、rotation 0。卡片堆获得焦点时也支持左右方向键。卡片显示马来西亚城市名:吉隆坡、槟城、马六甲、怡保。在 prefers-reduced-motion 下不做甩出动画,只用 150ms 的淡入淡出轮换卡片。
源代码
'use client'
import gsap from 'gsap'
import { useLayoutEffect, useRef, useState } from 'react'
import s from './demo.module.css'
const THROW = 110
export default function SwipeStack({ labels }: { labels: Record<string, string> }) {
// the last element renders on top, so reverse to put the first city on top
const [order, setOrder] = useState(() => labels.cards.split('|').reverse())
const stack = useRef<HTMLDivElement>(null)
const drag = useRef({ x0: 0, x: 0, active: false })
// Lay the stack out after every reorder: each level behind the top shrinks and drops a little.
useLayoutEffect(() => {
const cards = Array.from(stack.current?.children ?? []) as HTMLElement[]
cards.forEach((card, i) => {
const level = cards.length - 1 - i
gsap.to(card, { x: 0, rotation: 0, opacity: 1, scale: 1 - level * 0.05, y: level * 10, duration: 0.5, ease: 'power3.out' })
})
}, [order])
const top = () => stack.current?.lastElementChild as HTMLElement | null
const fling = (dir: 1 | -1) => {
const card = top()
if (!card) return
const done = () => {
// park the thrown card invisibly at the centre so it fades in at the back instead of flying back
gsap.set(card, { x: 0, rotation: 0, opacity: 0 })
setOrder((o) => [o[o.length - 1], ...o.slice(0, -1)])
}
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
gsap.to(card, { opacity: 0, duration: 0.15, onComplete: done })
return
}
gsap.to(card, { x: dir * 400, rotation: dir * 25, opacity: 0, duration: 0.4, ease: 'power2.in', onComplete: done })
}
const onDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (e.target !== top() && !top()?.contains(e.target as Node)) return
e.currentTarget.setPointerCapture(e.pointerId)
drag.current = { x0: e.clientX, x: 0, active: true }
}
const onMove = (e: React.PointerEvent) => {
if (!drag.current.active) return
drag.current.x = e.clientX - drag.current.x0
gsap.set(top(), { x: drag.current.x, rotation: drag.current.x * 0.08 })
}
const onUp = () => {
if (!drag.current.active) return
drag.current.active = false
const { x } = drag.current
if (Math.abs(x) > THROW) fling(x > 0 ? 1 : -1)
else gsap.to(top(), { x: 0, rotation: 0, duration: 0.5, ease: 'expo.out' })
}
return (
<div className={s.wrap}>
<div
ref={stack}
className={s.stack}
tabIndex={0}
role="group"
aria-label={labels.hint}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') fling(-1)
if (e.key === 'ArrowRight') fling(1)
}}
>
{order.map((city, i) => (
<div key={city} className={s.card} aria-hidden={i !== order.length - 1}>
<span className={s.n}>{String(labels.cards.split('|').indexOf(city) + 1).padStart(2, '0')}</span>
<strong className={s.city}>{city}</strong>
</div>
))}
</div>
<span className={s.hint}>{labels.hint}</span>
</div>
)
}
.wrap { display: grid; justify-items: center; gap: 44px; }
.stack {
position: relative;
width: 170px;
height: 130px;
touch-action: none;
border-radius: 16px;
}
.stack:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 6px; }
.card {
position: absolute;
inset: 0;
display: grid;
align-content: space-between;
padding: 16px;
border: 1px solid var(--stage-rule);
border-radius: 16px;
background: var(--stage-surface);
color: var(--stage-ink);
cursor: grab;
user-select: none;
transform-origin: 50% 100%;
}
.card:last-child { background: var(--stage-ink); color: var(--stage-bg); border-color: var(--stage-ink); }
.card:last-child:active { cursor: grabbing; }
.n { font: 500 var(--text-xs) / 1 var(--font-mono); color: var(--stage-accent); }
.city { font: 600 var(--text-xl) / 1.15 var(--font-display); }
.hint { font-size: var(--text-xs); color: var(--stage-muted); }