滑动确认

把滑块拖过轨道才算确认;中途松手会弹回原位,误触永远不会触发操作。

  • JS
  • 按钮
  • 拖拽
  • 收录于

AI 提示词

用 React 和指针事件制作滑动确认控件,不使用第三方库。胶囊形轨道左侧放一个圆形滑块,中间是提示文字。pointerdown 时捕获指针;pointermove 时把滑块位移限制在 0 到“轨道宽度减滑块宽度”之间,用 transform 设置,并根据进度淡出提示文字。pointerup 时,如果进度超过 90% 就吸附到终点并切换到完成状态(轨道填充主色,文字变为“已支付”),否则用 300ms、cubic-bezier(0.34, 1.56, 0.64, 1) 的过渡弹回 0。拖动时关闭过渡,让滑块精确跟随手指。滑块本身是一个按钮:键盘用户按 Enter 或 Space 可直接确认。确认后显示一个小的“重置”按钮。在 prefers-reduced-motion 下去掉回弹缓动。提示文字为“滑动支付”。

源代码

Demo.tsx
'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>
  )
}
demo.module.css
.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; }
}

投稿创意

看到想收录的动效或主题?简单描述一下,会打开一个预先填好的 GitHub Issue,确认后即可提交。

你想投稿的是?

需要 GitHub 账号,投稿内容会公开。