滑动删除

drag · swipe-to-dismiss · 灵感来源:iOS 邮件 / 通知列表的左滑删除(UITableView swipe actions)

构

构建完成

main 分支已部署到预览环境

2 分钟前
收

收到 3 条新评论

组件矩阵页 · 交互建议

18 分钟前
依

依赖更新

astro 5.18 → 5.19 可用

1 小时前
周

周报已生成

本周合并 12 个提交

昨天

向左滑删除 · 聚焦后按 Delete

复用卡

Swipe Actions · 滑动删除 · 拖拽 / 手势

规格 · 术语提示词(给 AI 用)

做一个 Swipe Actions(滑动删除):
- 四段链:1:1 跟手 → 越界阻尼 30% → 阈值 96px / 速度 0.5px/ms 双判 → 吸附或飞出
- 轴锁:横向起手才接管,纵向让位给滚动(touch-action: pan-y)
- 测速只取最后 120ms 的位移,不用全程平均
- 键盘等价:行可聚焦,Delete / Backspace 删除,结果进 aria-live
- reduced-motion 下不画飞出过程,直接提交
用这些 CSS 变量:--color-accent --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-bg --color-warning-fg --dur-fast --ease-standard --glass-border --text-2xs
灵感参考:iOS 邮件 / 通知列表的左滑删除(UITableView swipe actions)

参数(CSS 变量)

--color-accent --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-bg --color-warning-fg --dur-fast --ease-standard --glass-border --text-2xs

必缺状态

焦点 / 按压

整页场景

安装(一条链接)

调研笔记

滑动删除

拆解

  • 四段链完整走一遍:1:1 跟手 → 越界阻尼 → 阈值 / 速度双判 → 吸附或飞出
  • 只有向左滑有效,右边才是删除区;横向起手才接管,纵向让位给页面滚动(轴锁)
  • 越界后那部分只跟 30%:手还在动,但能感到"拉住了"
  • 判定是速度与距离二选一:慢慢拖要过 96px,快甩只要 ≥0.5px/ms 就成立——"放过去"和"甩过去"是两种意图

实现取舍

  • 用 setPointerCapture,不用 HTML5 drag-and-drop;touch-action: pan-y 让纵向滚动照常
  • 测速取最后 120ms 的位移,不用全程平均——起手的第一下会把平均值拖偏
  • 飞出用 --dur-fast 的时长收尾,不依赖 transitionend:页面隐藏、或过渡被外部样式掐掉时它不会来,行会卡在屏外却还在 DOM 里。JS 读的是同一个 token,不写第二份
  • prefers-reduced-motion 下不画飞出过程,直接调用 onDismiss
  • 键盘等价:行可聚焦,Delete / Backspace 删除;删除结果写进 aria-live,读屏能听到"还剩几条"
  • 只在行内没有按钮 / 链接的地方起拖,避免和行内操作抢手势

变量:--color-warning-bg / --color-warning-fg / --dur-fast / --ease-standard。

实现

drag/swipe-to-dismiss
import { useRef, useState, type KeyboardEvent, type PointerEvent, type ReactNode } from 'react';

interface Props {
  children: ReactNode;
  onDismiss?: () => void;
  /** 触发关闭的距离阈值(px) */
  threshold?: number;
  /** 露出的背景上写什么 */
  dismissLabel?: string;
  className?: string;
}

/** 越界之后的跟手比例:越过阈值仍在动,但不再 1:1 */
const DAMP = 0.3;
/** 甩出去就算删除的速度线(px/ms) */
const FLING = 0.5;
/** 测速窗口:只取最后这一段位移,更早的样本不参与 */
const WINDOW_MS = 120;

interface Sample {
  x: number;
  t: number;
}

const reducedMotion = () =>
  typeof window !== 'undefined' && window.matchMedia('(prefers-reduced-motion: reduce)').matches;

/** 读出场的 token 时长,保证 JS 收尾与 CSS 过渡是同一个值 */
const exitDuration = () => {
  const raw = getComputedStyle(document.documentElement).getPropertyValue('--dur-fast').trim();
  const n = parseFloat(raw) || 0;
  return raw.endsWith('ms') ? n : n * 1000;
};

export function SwipeToDismiss({
  children,
  onDismiss,
  threshold = 96,
  dismissLabel = '松手删除',
  className = '',
}: Props) {
  const [dx, setDx] = useState(0);
  const [settling, setSettling] = useState<'idle' | 'snap' | 'out'>('idle');
  const rowRef = useRef<HTMLDivElement>(null);
  const drag = useRef<{ id: number; startX: number; startY: number; axis: 'x' | 'y' | null; samples: Sample[] } | null>(
    null,
  );

  /** 越界后阻尼:超过阈值的那部分只跟 30%,手感"拉住"但不硬停 */
  const damp = (raw: number) => {
    const over = Math.abs(raw) - threshold;
    if (over <= 0) return raw;
    return Math.sign(raw) * (threshold + over * DAMP);
  };

  const velocity = () => {
    const s = drag.current?.samples ?? [];
    if (s.length < 2) return 0;
    const last = s[s.length - 1];
    let first = s[0];
    for (const sample of s) {
      if (last.t - sample.t <= WINDOW_MS) {
        first = sample;
        break;
      }
    }
    const dt = last.t - first.t;
    return dt === 0 ? 0 : (last.x - first.x) / dt;
  };

  const flyOut = () => {
    const el = rowRef.current;
    if (!el) return;
    if (reducedMotion()) {
      onDismiss?.();
      return;
    }
    setSettling('out');
    setDx(-el.offsetWidth);
    // 用 token 时长收尾,不依赖 transitionend:页面隐藏或过渡被外部样式掐掉时它不会来
    window.setTimeout(() => onDismiss?.(), exitDuration());
  };

  const onPointerDown = (e: PointerEvent<HTMLDivElement>) => {
    if (settling === 'out') return;
    // 行内如果放了按钮 / 链接,让它们自己处理,不从它们身上起拖
    if ((e.target as HTMLElement).closest('a, button, input, select, textarea')) return;
    e.currentTarget.setPointerCapture(e.pointerId);
    drag.current = { id: e.pointerId, startX: e.clientX, startY: e.clientY, axis: null, samples: [] };
    setSettling('idle');
  };

  const onPointerMove = (e: PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;

    const moveX = e.clientX - d.startX;
    const moveY = e.clientY - d.startY;

    // 轴锁:先动哪边算哪边,纵向就让位给滚动
    if (!d.axis) {
      if (Math.abs(moveX) < 8 && Math.abs(moveY) < 8) return;
      if (Math.abs(moveY) >= Math.abs(moveX)) {
        drag.current = null;
        e.currentTarget.releasePointerCapture(e.pointerId);
        return;
      }
      d.axis = 'x';
    }

    d.samples.push({ x: e.clientX, t: e.timeStamp });
    if (d.samples.length > 20) d.samples.shift();

    // 只允许向左滑:右边才是删除区
    setDx(Math.min(0, damp(moveX)));
  };

  const settle = (e: PointerEvent<HTMLDivElement>) => {
    const d = drag.current;
    if (!d || d.id !== e.pointerId) return;
    drag.current = null;

    const distance = Math.abs(dx);
    const speed = Math.abs(velocity());

    // 速度与距离双条件:甩得够快不用滑到底,慢慢拖必须过阈值
    if (distance >= threshold || speed >= FLING) {
      flyOut();
    } else {
      setSettling('snap');
      setDx(0);
    }
  };

  /** 键盘等价:行可聚焦,Delete / Backspace 直接删除 */
  const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
    if (e.key !== 'Delete' && e.key !== 'Backspace') return;
    e.preventDefault();
    flyOut();
  };

  return (
    <div className={`relative overflow-hidden ${className}`}>
      <div
        aria-hidden="true"
        className="absolute inset-0 flex items-center justify-end pr-4 bg-[var(--color-warning-bg)] text-xs text-[var(--color-warning-fg)]"
      >
        {dismissLabel}
      </div>

      <div
        ref={rowRef}
        role="group"
        aria-roledescription="可滑动删除"
        tabIndex={0}
        onPointerDown={onPointerDown}
        onPointerMove={onPointerMove}
        onPointerUp={settle}
        onPointerCancel={settle}
        onKeyDown={onKeyDown}
        className="relative touch-pan-y bg-[var(--color-surface)] motion-reduce:transition-none"
        style={{
          transform: `translateX(${dx}px)`,
          transition: settling === 'idle' ? 'none' : 'transform var(--dur-fast) var(--ease-standard)',
        }}
      >
        {children}
      </div>
    </div>
  );
}

元信息

标识
drag/swipe-to-dismiss
状态
done
最后提交
2026-10-31
风格轴
swipe-to-dismiss
依赖 token
--color-accent --color-surface --color-text --color-text-muted --color-text-subtle --color-warning-bg --color-warning-fg --dur-fast --ease-standard --glass-border --text-2xs
在工作台打开 源码与 notes 都在 src/components/drag/swipe-to-dismiss/