滑动确认
把滑块拖过轨道才算确认;中途松手会弹回原位,误触永远不会触发操作。
用 React 和指针事件制作滑动确认控件,不使用第三方库。胶囊形轨道左侧放一个圆形滑块,中间是提示文字。pointerdown 时捕获指针;pointermove 时把滑块位移限制在 0 到“轨道宽度减滑块宽度”之间,用 transform 设置,并根据进度淡出提示文字。pointerup 时,如果进度超过 90% 就吸附到终点并切换到完成状态(轨道填充主色,文字变为“已支付”),否则用 300ms、cubic-bezier(0.34, 1.56, 0.64, 1) 的过渡弹回 0。拖动时关闭过渡,让滑块精确跟随手指。滑块本身是一个按钮:键盘用户按 Enter 或 Space 可直接确认。确认后显示一个小的“重置”按钮。在 prefers-reduced-motion 下去掉回弹缓动。提示文字为“滑动支付”。
源代码
'use client'
import { useRef, useState } from 'react'
import s from './demo.module.css'
export default function SlideToConfirm({ labels }: { labels: Record<string, string> }) {
const track = useRef<HTMLDivElement>(null)
const knob = useRef<HTMLButtonElement>(null)
const start = useRef<number | null>(null)
// pointerup can land before React re-renders, so read the latest offset from a ref
const pos = useRef(0)
const [x, setXState] = useState(0)
const setX = (v: number) => {
pos.current = v
setXState(v)
}
const [dragging, setDragging] = useState(false)
const [done, setDone] = useState(false)
const max = () => (track.current?.clientWidth ?? 0) - (knob.current?.offsetWidth ?? 0) - 8
const progress = done ? 1 : x / (max() || 1)
const onDown = (e: React.PointerEvent<HTMLButtonElement>) => {
if (done) return
e.currentTarget.setPointerCapture(e.pointerId)
start.current = e.clientX - pos.current
setDragging(true)
}
const onMove = (e: React.PointerEvent) => {
if (start.current === null) return
setX(Math.min(Math.max(e.clientX - start.current, 0), max()))
}
const onUp = () => {
if (start.current === null) return
start.current = null
setDragging(false)
if (pos.current / max() > 0.9) confirm()
else setX(0)
}
const confirm = () => {
setX(max())
setDone(true)
}
const reset = () => {
setDone(false)
setX(0)
}
return (
<div className={s.wrap}>
<div ref={track} className={s.track} data-done={done || undefined}>
<span className={s.hint} style={{ opacity: 1 - progress }} aria-hidden="true">
{labels.hint}
</span>
<span className={s.doneLabel} aria-live="polite">
{done ? labels.done : ''}
</span>
<button
ref={knob}
type="button"
className={s.knob}
aria-label={labels.hint}
disabled={done}
style={{ transform: `translateX(${x}px)`, transition: dragging ? 'none' : undefined }}
onPointerDown={onDown}
onPointerMove={onMove}
onPointerUp={onUp}
onPointerCancel={onUp}
onKeyDown={(e) => (e.key === 'Enter' || e.key === ' ') && (e.preventDefault(), confirm())}
>
<svg viewBox="0 0 24 24" aria-hidden="true">
{done ? <path d="M5 12.5l4.5 4.5L19 7.5" /> : <path d="M9 6l6 6-6 6" />}
</svg>
</button>
</div>
<button type="button" className={s.reset} onClick={reset} hidden={!done}>
{labels.reset}
</button>
</div>
)
}
.wrap {
display: grid;
justify-items: center;
gap: 12px;
width: min(280px, 100%);
}
.track {
position: relative;
display: grid;
place-items: center;
width: 100%;
height: 56px;
padding: 4px;
border-radius: 999px;
background: var(--stage-surface);
box-shadow: inset 0 0 0 1px var(--stage-rule);
transition: background-color var(--dur-long) var(--ease-out);
}
.track[data-done] { background: var(--stage-accent); }
.hint, .doneLabel {
grid-area: 1 / 1;
font: 500 var(--text-sm) / 1 var(--font-body);
letter-spacing: 0.02em;
color: var(--stage-muted);
pointer-events: none;
}
.doneLabel { color: var(--stage-accent-ink); font-weight: 600; }
.knob {
position: absolute;
left: 4px;
top: 4px;
display: grid;
place-items: center;
width: 48px;
height: 48px;
border: 0;
border-radius: 50%;
background: var(--stage-accent);
color: var(--stage-accent-ink);
cursor: grab;
touch-action: none;
transition: transform 300ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.knob:active { cursor: grabbing; }
.knob:disabled { cursor: default; background: var(--stage-accent-ink); color: var(--stage-accent); }
.knob:focus-visible { outline: 2px solid var(--color-focus); outline-offset: 3px; }
.knob svg {
width: 20px;
height: 20px;
fill: none;
stroke: currentColor;
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
}
.reset {
height: 28px;
padding: 0 10px;
border: 1px solid var(--stage-rule);
border-radius: var(--radius-sm);
background: var(--stage-surface);
color: var(--stage-ink);
font: 500 var(--text-xs) / 1 var(--font-body);
cursor: pointer;
}
.reset[hidden] { visibility: hidden; display: block; }
@media (prefers-reduced-motion: reduce) {
.knob { transition: transform 120ms linear; }
}