长按确认删除
Hold to Delete按住不放,进度环走完才真正删除——松早一点就作废
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
指针交互按住不放,进度环走完才真正删除;中途松开、指针移出或按 Escape 都会取消并退回去。
键盘:Tab 聚焦 · 按住 Space / Enter · 松开取消
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4,不依赖任何动画库。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现一个长按确认删除按钮,按钮盒 216px × 52px。按下后 3px 的进度环在 1500ms 内线性填满,只有填满才触发一次删除;提前松开、指针移出按钮或用键盘取消,环在 180ms 内退回且不删除。按钮是真实的 <button>:按住 Space / Enter 开始计时,keyup 取消、Escape 作废;删除后变成绿色"已删除"帧,demo 提供撤销。reduced motion 下也不允许缩短倒计时。
文件契约
- 只新建一个文件:components/effects/HoldToDelete.tsx。
- 依赖:无。用 requestAnimationFrame 驱动进度,CSS transition 只负责取消时的回退;不要新增依赖。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";受控/非受控都可用:deleted 由父组件传入,onConfirm 在环形进度填满的那一帧调用一次。
- 进度环是绝对定位在按钮盒上的 SVG <rect>,pathLength={100},stroke-dasharray: 100,用 stroke-dashoffset = 100 * (1 - progress) 表示进度;环不得参与布局,保证按钮的 216 × 52 盒尺寸在按住期间完全不变。
- 按下(onPointerDown)时记录 performance.now() 并启动一个 requestAnimationFrame 循环:progress = min(1, (now - start) / holdMs),每帧直接写 strokeDashoffset,不要经过 React state;进度到 1 时取消 rAF 并调用一次 onConfirm()。
- 取消路径:onPointerUp / onPointerLeave / onPointerCancel / onKeyUp(Space、Enter)/ Escape 都调用同一个 cancel():取消 rAF,给环加上 transition: stroke-dashoffset <resetMs>ms linear 并按 progress * resetMs 的剩余比例把 dashoffset 退回 100,过渡结束后移除内联 transition。
- 键盘:keydown 里对 Space / Enter 调 preventDefault() 开始计时(event.repeat 时忽略),keyup 取消;按钮始终保持原生 <button type="button">、可聚焦、不设 disabled。aria-label 写明需要按住几秒,另用 aria-live="polite" 播报开始 / 取消 / 已删除。
- prefers-reduced-motion: reduce:取消与删除的状态切换压到 ≤1ms,但 holdMs 计时不许缩短(安全要求),进度仍按真实时间推进;删除后的绿色终帧直接呈现。卸载或 deleted 变化时清掉 rAF、回退 timer 与事件监听。
规格 —— 精确数值,不许四舍五入或“优化”
- 按钮盒:216px × 52px,圆角 14px,左右内边距 16px,字号 13px;图标与文字间距 10px。盒尺寸锁死,任何阶段都不许动。
- 进度环:覆盖在按钮盒外 4px 处的 SVG,描边 3px(即 ringWidth 参数),pathLength 100,周长约 568px(用户单位),从顶部开始顺时针、圆头端点;用 stroke-dashoffset = 100 × (1 − progress) 表示进度,底环保持 rgba(201, 244, 90, 0.14)。
- 按住:pointerdown(或键盘 Space / Enter 的 keydown)后进度在 1500ms 内从 0 线性走到 1;只有填满才确认——进度精确到 1 的那一帧触发一次删除。
- 取消:提前松开、pointerleave、pointercancel、keyup 或 Escape 都会停止计时,并让环在最多 180ms 内按已显示的比例退回空心(与已推进的比例成正比),随后移除内联 transition。删除不会发生。
- 已删除帧:边框 rgba(74, 222, 128, 0.55)、背景 rgba(74, 222, 128, 0.12)、文字 #4ade80,显示对勾与"已删除 / Deleted";进度环隐去(dashoffset 100)。demo 提供"撤销",恢复 idle 并重新可按住。
- idle 视觉:边框 rgba(237, 238, 240, 0.16)、背景 rgba(237, 238, 240, 0.04)、文字 #edeef0;按住时边框 rgba(201, 244, 90, 0.55)、背景 rgba(201, 244, 90, 0.10);进度描边 #c9f45a,完成瞬间转 #4ade80。
- 只允许变化 stroke-dashoffset、transform、opacity、background-color、border-color、color;绝不允许动画 width、height、top、left、margin、padding。
行为
- 触发元素是真实的 <button type="button">;pointerdown / keydown 开始计时,pointerup / pointerleave / pointercancel / keyup / Escape 取消计时。
- 文字状态机:idle --pointerdown 或 keydown(Space/Enter)--> holding(进度在 1500ms 内线性 0 → 1,rAF 驱动)--> confirmed(进度精确到 1,onConfirm 只触发一次)--> deleted;holding --pointerup 或 pointerleave 或 pointercancel 或 keyup 或 Escape--> cancelling(环在 ≤180ms 内退回)--> idle;deleted --撤销--> idle;任意状态 --reduced motion--> 长按时长不变,仅状态切换压到 ≤1ms。
- 防重复进入:按住期间忽略后续 pointerdown / 重复 keydown(event.repeat);确认之后要等父组件复位才能再次按住。绝不设置 disabled——那会丢掉焦点。
- 进度用唯一的 requestAnimationFrame 循环直接写 stroke-dashoffset;不要在 React state 里每帧更新进度,也不要链式 setTimeout。
- 取消路径:停掉 rAF,给环加 transition: stroke-dashoffset 180ms linear,按已显示的比例把环退回空心,回退结束后移除内联 transition。
- 键盘与无障碍:按钮可聚焦;aria-label 写明需要按住几秒;aria-live="polite" 播报开始 / 取消 / 已删除;focus-visible 描边 2px #C9F45A、外扩 3px;手势过程中可访问名称不改变。
- prefers-reduced-motion:holdMs 保持 1500ms、进度按真实时间线性推进,只有取消 / 删除的视觉状态切换压到 ≤1ms;破坏性操作的等待不允许被缩短。
- 卸载时清理:取消 rAF、清掉回退 timer,销毁后不要有任何状态更新。
禁止
- 不要改变交互语义、可访问名称或删除真正触发的时机;这是同一个破坏性操作的视觉升级,不是新交互。
- 不要逐帧动画 width、height、top、left、margin 或 padding——按钮盒锁死,环是绝对定位覆盖层。
- 不要链式 setTimeout,也不要用 setInterval 推进进度;只允许一个 rAF 循环,松开与卸载时都要取消。
- 不要缩短、造假或跳过 1500ms 倒计时,reduced motion 下也一样;除非用户明确要求,进度也必须线性(不要缓动)。
- 不要用 disabled、pointer-events: none,也不要用 setPointerCapture 让 pointerleave / pointercancel 无法取消长按。
- 不要重复触发 onConfirm,也绝不能在环填满之前靠 pointerup 确认删除。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 除 holdMs / resetMs 参数与文档默认值外不要硬编码按住或回退时长。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
- 除 Motion 外不要引入任何动画库,更不要用 GSAP。
验收 —— 回答前逐项自查
1. 尺寸、进度与三个参数严格一致:按钮 216 × 52px、圆角 14px;进度环描边精确是 3px(可调参数时跟随 ringWidth),在 1500ms 内从空心线性填满,取消时在 180ms 内退回;删除终帧文案与绿色状态精确切换。
2. 按下后 60–80ms 采样:stroke-dashoffset 严格处于满值与该时刻应有值之间(既不是 0 也不是 100),按钮边框/背景处于过渡中的中间色,说明进度是连续推进而不是瞬跳。
3. 结束帧无残留:确认时 stroke-dashoffset 精确为 0;取消时精确回到满值,且回退过渡结束后内联 transition 已被移除,没有仍在运行的 rAF、timer 或过渡。
4. 整个按住、取消与删除过程中按钮父容器的 boundingBox 完全不变(216 × 52px),环只是绝对定位的覆盖层,不参与布局,页面不抖动、不出现滚动条跳动。
5. prefers-reduced-motion 下取消与删除的状态切换 ≤1ms;但长按计时 holdMs 不允许被缩短或跳过,进度仍按真实时间线性推进(破坏性操作的等待是安全语义,不是装饰,此条与通用验收的差异是有意为之)。
6. 键盘可完成完整操作:按钮可聚焦、Tab 后 focus-visible 为 2px #C9F45A、外扩 3px;按住 Space / Enter 在 keydown 开始计时、keyup 取消,Escape 直接作废;aria-label 写明需按住几秒,aria-live 播报开始 / 取消 / 已删除,且永不设置 disabled。
7. 零 console 报错;pointerup / pointerleave / pointercancel / 卸载后 rAF 与回退 timer 均已清理,没有残留的事件监听或指针捕获。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import { useEffect, useRef, useState } from "react";
import { HOLD } from "@/content/effects/hold-to-delete/constants";
const FULL = 100;
type HoldToDeleteMessages = {
holding: string;
cancelled: string;
deleted: string;
};
type HoldToDeleteProps = {
holdMs?: number;
resetMs?: number;
ringWidth?: number;
/** visible label, also the base of the accessible name */
label?: string;
deletedLabel?: string;
/** full localized accessible name; should state how many seconds to hold */
ariaLabel?: string;
messages?: Partial<HoldToDeleteMessages>;
deleted?: boolean;
onConfirm?: () => void;
className?: string;
};
const DEFAULT_MESSAGES: HoldToDeleteMessages = {
holding: "Hold to delete",
cancelled: "Cancelled",
deleted: "Deleted",
};
export function HoldToDelete({
holdMs = HOLD.defaults.holdMs,
resetMs = HOLD.defaults.resetMs,
ringWidth = HOLD.defaults.ringWidth,
label = "Delete project",
deletedLabel = "Deleted",
ariaLabel,
messages,
deleted = false,
onConfirm,
className = "",
}: HoldToDeleteProps) {
const buttonRef = useRef<HTMLButtonElement | null>(null);
const ringRef = useRef<SVGRectElement | null>(null);
const rafRef = useRef(0);
const startedAtRef = useRef(0);
const progressRef = useRef(0);
const holdingRef = useRef(false);
const retreatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const confirmRef = useRef(onConfirm);
const [holding, setHolding] = useState(false);
const [status, setStatus] = useState<"idle" | "holding" | "cancelled" | "confirmed">("idle");
useEffect(() => {
confirmRef.current = onConfirm;
}, [onConfirm]);
function setRingOffset(offset: number, transitionMs: number | null) {
const el = ringRef.current;
if (!el) return;
el.style.transition =
transitionMs === null ? "none" : `stroke-dashoffset ${transitionMs}ms linear`;
el.style.strokeDashoffset = String(offset);
}
function stopRaf() {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
}
function clearRetreatTimer() {
if (retreatTimerRef.current !== null) {
clearTimeout(retreatTimerRef.current);
retreatTimerRef.current = null;
}
}
function tick(now: number) {
if (!holdingRef.current) return;
const progress = Math.min(1, (now - startedAtRef.current) / holdMs);
progressRef.current = progress;
setRingOffset(FULL * (1 - progress), null);
if (progress >= 1) {
finish();
return;
}
rafRef.current = requestAnimationFrame(tick);
}
function start() {
if (deleted || holdingRef.current) return;
clearRetreatTimer();
stopRaf();
holdingRef.current = true;
progressRef.current = 0;
setHolding(true);
setStatus("holding");
setRingOffset(FULL, null);
startedAtRef.current = performance.now();
rafRef.current = requestAnimationFrame(tick);
}
function finish() {
stopRaf();
holdingRef.current = false;
progressRef.current = 1;
setHolding(false);
setStatus("confirmed");
setRingOffset(0, null);
confirmRef.current?.();
}
function cancel() {
if (!holdingRef.current) return;
stopRaf();
holdingRef.current = false;
setHolding(false);
setStatus("cancelled");
const shown = progressRef.current;
progressRef.current = 0;
const retreatMs = Math.round(shown * resetMs);
setRingOffset(FULL, retreatMs);
clearRetreatTimer();
retreatTimerRef.current = setTimeout(
() => {
setRingOffset(FULL, null);
retreatTimerRef.current = null;
},
retreatMs + 40,
);
}
useEffect(() => {
stopRaf();
clearRetreatTimer();
holdingRef.current = false;
setHolding(false);
if (deleted) {
progressRef.current = 1;
setStatus("confirmed");
setRingOffset(0, null);
} else {
progressRef.current = 0;
setStatus("idle");
setRingOffset(FULL, null);
}
}, [deleted, resetMs]);
useEffect(
() => () => {
stopRaf();
clearRetreatTimer();
},
[],
);
function handlePointerDown(event: React.PointerEvent<HTMLButtonElement>) {
if (deleted) return;
if (event.pointerType === "mouse" && event.button !== 0) return;
event.preventDefault();
buttonRef.current?.focus({ preventScroll: true });
start();
}
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
if (deleted) return;
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (event.repeat) return;
start();
return;
}
if (event.key === "Escape") {
event.preventDefault();
cancel();
}
}
function handleKeyUp(event: React.KeyboardEvent<HTMLButtonElement>) {
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
cancel();
}
}
const seconds = (holdMs / 1000).toFixed(1);
const state = deleted ? "deleted" : holding ? "holding" : "idle";
const palette = deleted
? HOLD.colors.deleted
: holding
? HOLD.colors.holding
: HOLD.colors.idle;
const accessibleName = deleted
? deletedLabel
: ariaLabel ?? `${label} — hold ${seconds}s to confirm`;
const copy = { ...DEFAULT_MESSAGES, ...messages };
const statusText =
status === "holding"
? copy.holding
: status === "cancelled"
? copy.cancelled
: status === "confirmed"
? copy.deleted
: "";
const ringBox = {
width: HOLD.button.width + HOLD.ring.outset * 2,
height: HOLD.button.height + HOLD.ring.outset * 2,
};
return (
<button
ref={buttonRef}
type="button"
data-state={state}
aria-label={accessibleName}
className={`relative inline-flex select-none items-center justify-center gap-[10px] rounded-[14px] border bg-transparent px-4 text-[13px] font-medium outline-none transition-[background-color,border-color,color] duration-200 focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#c9f45a] ${className}`}
style={{
width: HOLD.button.width,
height: HOLD.button.height,
borderColor: palette.border,
backgroundColor: palette.background,
color: palette.text,
fontSize: HOLD.button.fontSize,
}}
onPointerDown={handlePointerDown}
onPointerUp={cancel}
onPointerLeave={cancel}
onPointerCancel={cancel}
onKeyDown={handleKeyDown}
onKeyUp={handleKeyUp}
onBlur={cancel}
onContextMenu={(event) => event.preventDefault()}
>
<svg
aria-hidden="true"
className="pointer-events-none absolute"
style={{
left: -HOLD.ring.outset,
top: -HOLD.ring.outset,
width: ringBox.width,
height: ringBox.height,
opacity: deleted ? 0 : 1,
transition: "opacity 160ms linear",
}}
viewBox={`0 0 ${ringBox.width} ${ringBox.height}`}
>
<rect
x={HOLD.ring.outset}
y={HOLD.ring.outset}
width={HOLD.button.width}
height={HOLD.button.height}
rx={HOLD.button.radius}
fill="none"
stroke={HOLD.colors.ringTrack}
strokeWidth={ringWidth}
/>
<rect
ref={ringRef}
x={HOLD.ring.outset}
y={HOLD.ring.outset}
width={HOLD.button.width}
height={HOLD.button.height}
rx={HOLD.button.radius}
fill="none"
stroke={HOLD.colors.ringProgress}
strokeWidth={ringWidth}
strokeLinecap="round"
pathLength={100}
strokeDasharray={100}
style={{ strokeDashoffset: FULL }}
/>
</svg>
{deleted ? (
<svg
aria-hidden="true"
width={HOLD.icon.size}
height={HOLD.icon.size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={2.2}
strokeLinecap="round"
strokeLinejoin="round"
className="relative"
>
<path d="M5 12.5l4.2 4L19 7" />
</svg>
) : (
<svg
aria-hidden="true"
width={HOLD.icon.size}
height={HOLD.icon.size}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={HOLD.icon.stroke}
strokeLinecap="round"
strokeLinejoin="round"
className="relative"
>
<path d="M4 7h16" />
<path d="M9 7V5h6v2" />
<path d="M6 7l1 12h10l1-12" />
<path d="M10 11v5" />
<path d="M14 11v5" />
</svg>
)}
<span className="relative truncate">{deleted ? deletedLabel : label}</span>
<span className="relative font-mono text-[10px] tracking-wide opacity-60">
{deleted ? "" : `${seconds}s`}
</span>
<span aria-live="polite" className="sr-only">
{statusText}
</span>
</button>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
一个 216px × 52px 的删除按钮,外面贴着一圈 3px 的进度环。按下后进度在 1500ms 内线性填满,填满的瞬间才触发删除;中途松开、指针移出(pointerleave / pointercancel)或用键盘提前抬键都会在 180ms 内把进度退回去,删除不会发生。键盘同样可完成:聚焦后按住 Space 或 Enter 开始计时,keyup 取消,Escape 直接作废。删除成功后按钮切到绿色的"已删除"帧,demo 里提供"撤销"恢复。长按计时在减少动态效果下也不会被缩短,安全相关的时长不允许变成装饰参数。
- 按住时长
- 1500 ms
- 回退时长
- 180 ms
- 进度环描边
- 3 px
- 按钮盒
- 216 × 52 px · r14
- 按住 → 删除
- 1500 ms · 1.5 s
- 取消回退
- 180 ms
- 环描边
- 3 px
- 环总周长
- 568 px
- 进度速度
- 66.7 %/s
- 键盘
- Space / Enter 按住 · keyup 取消
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 尺寸、进度与三个参数严格一致:按钮 216 × 52px、圆角 14px;进度环描边精确是 3px(可调参数时跟随 ringWidth),在 1500ms 内从空心线性填满,取消时在 180ms 内退回;删除终帧文案与绿色状态精确切换。
- 按下后 60–80ms 采样:`stroke-dashoffset` 严格处于满值与该时刻应有值之间(既不是 0 也不是 100),按钮边框/背景处于过渡中的中间色,说明进度是连续推进而不是瞬跳。
- 结束帧无残留:确认时 `stroke-dashoffset` 精确为 0;取消时精确回到满值,且回退过渡结束后内联 `transition` 已被移除,没有仍在运行的 rAF、timer 或过渡。
- 整个按住、取消与删除过程中按钮父容器的 boundingBox 完全不变(216 × 52px),环只是绝对定位的覆盖层,不参与布局,页面不抖动、不出现滚动条跳动。
- prefers-reduced-motion 下取消与删除的状态切换 ≤1ms;但长按计时 holdMs 不允许被缩短或跳过,进度仍按真实时间线性推进(破坏性操作的等待是安全语义,不是装饰,此条与通用验收的差异是有意为之)。
- 键盘可完成完整操作:按钮可聚焦、Tab 后 focus-visible 为 2px #C9F45A、外扩 3px;按住 Space / Enter 在 keydown 开始计时、keyup 取消,Escape 直接作废;`aria-label` 写明需按住几秒,`aria-live` 播报开始 / 取消 / 已删除,且永不设置 `disabled`。
- 零 console 报错;pointerup / pointerleave / pointercancel / 卸载后 rAF 与回退 timer 均已清理,没有残留的事件监听或指针捕获。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。