提交按钮三段变形
Submit Button Morph一次提交,按钮先收起文字、再旋成虚线环,最后画出一枚绿色对勾
完整版:适合从零新建组件,规格、契约、验收全都在。
实时预览
指针交互点击按钮,观察收起 → 加载环 → 对勾的三段变形
上次结果:等待提交
参数
边调边看,提示词同步更新。
角色
你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4 + Motion v14(motion 包)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。
任务
实现一个三段变形的提交按钮。点击后标签先收起 300ms,按钮盒锁在 236px × 54px 全程不变;接着出现一圈虚线环,走完固定 760ms 的加载窗;最后底色转绿,对勾在 280ms 内描边画出,按钮用 460ms 完成过冲落定。成功帧停留 900ms 后复位到 idle;提交失败则以红色叉号收尾,停留 900ms 后复位。
文件契约
- 只新建一个文件:components/effects/SubmitButton.tsx。
- 依赖:项目已安装 motion@14,从 motion/react 引入。不要新增任何其他包。
- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。
实现要点
- 文件顶部加 "use client";用 useState 保存 idle | shrinking | loading | success | error 五个状态,四段时长与 breathe 全部来自 props(默认值取自 constants)。
- 宽度锁死:按钮元素写死 width: 236px、height: 54px,标签、虚线环、对勾、叉号都是绝对定位居中的图层,任何文字或图标切换都不会改变布局。
- shrinking 阶段标签 opacity → 0、scale → 0.82(shrinkMs);loading 阶段渲染 SVG 虚线圆环,rotate: 360 线性无限循环(timeline.spinMs),breathe 为 true 时整环再做 scale/opacity 的往复呼吸。
- success 阶段背景与边框转绿,对勾用 pathLength: 0 → 1 在 checkMs 内描边(strokeLinecap: round),按钮 scale 走 [1, 1.05, 1] 在 bounceMs 内落定;成功后停留 successMs 复位。
- 每个状态只用 useEffect 挂一个 setTimeout 推进下一状态并在 cleanup 清除,禁止把 setTimeout 套进另一个回调;simulateError 时 loading 后进入红色 error 态 timeline.errorMs 再回 idle。
- 真实 <button type="button">:Enter/Space 走原生 click;动画期间不要用 disabled(会丢失焦点),用 phase !== "idle" 守卫重复点击,并加 aria-busy 与 aria-live="polite" 播报加载/成功/失败;useReducedMotion() 为 true 时点击直接进入成功终态(无收缩、无旋转、勾直接完整),successMs 后复位。
规格 —— 精确数值,不许四舍五入或“优化”
- 按钮盒:236px × 54px,圆角 16px,左右内边距 18px,字号 13px,字重 600,字距 0.01em。宽高锁死,任何阶段都不许动。
- 第一段 —— 文字收起:标签在 300ms 内 opacity 1 → 0、scale 1 → 0.82;按钮自身不改变尺寸。
- 第二段 —— 加载:出现 26px 的 SVG 虚线圆环(描边 2px,虚线 3.2 / 间隔 7,底环 rgba(237, 238, 240, 0.18)),匀速旋转 360°,900ms 转一圈,无限循环。
- 环呼吸:加载期间整个虚线环按 900ms 的节奏在 scale 1 → 1.055 → 1 之间往复,透明度最低到 0.62(环始终在按钮上居中)。
- 第三段 —— 成功:固定加载窗 760ms 结束后底色转绿——背景 rgba(74, 222, 128, 0.16)、边框 rgba(74, 222, 128, 0.55)、颜色 #4ade80、光晕 0 0 0 4px rgba(74, 222, 128, 0.08);22px 的对勾(描边 2.4px,viewBox 24,圆头端点)用 pathLength 0 → 1 在 280ms 内画出,同时按钮按 scale 1 → 1.05 → 1 在 460ms 内落定。
- 成功停留:停在成功终帧 900ms,然后一帧回到 idle(不要做反向动画)。
- 失败:提交失败时同一个加载窗结束后进入红色状态——颜色 #f87171、边框 rgba(248, 113, 113, 0.45)、背景 rgba(248, 113, 113, 0.12)——画出一个叉号,停留 900ms 后回到 idle。
- idle 视觉:颜色 #edeef0、边框 rgba(237, 238, 240, 0.16)、背景 rgba(237, 238, 240, 0.04);hover 边框 rgba(237, 238, 240, 0.32)。
- 只允许动画 transform、opacity、background-color、border-color、color;不允许任何导致按钮或父容器位移、尺寸变化的属性。
行为
- 触发元素是真实的 <button type="button">;点击(或原生触发 click 的 Enter/Space)从 idle 启动整段序列。
- 文字状态机:idle --click--> shrinking(300ms)--> loading(760ms)--> success(900ms)--> idle;loading --请求被拒绝--> error(900ms)--> idle;idle --reduced motion 点击--> success(终帧)--> idle;error --reduced motion--> 静态 error(终帧)--> idle。
- 防重复进入:phase 不是 idle 时忽略点击。动画期间绝不设置 disabled——那会丢掉焦点。
- 状态机每个阶段只挂一个 timeout(收起 300ms → 加载 760ms → 成功 900ms → idle);卸载时以及安排下一阶段前清掉未触发的 timeout。
- 加载期间标签已隐藏;在成功 / 失败帧出现之前,按钮的盒尺寸、背景与边框保持不变。
- 成功帧:对勾在整个 900ms 停留期内保持完整,然后按钮回到 idle。失败帧:停留 900ms 后回 idle。
- prefers-reduced-motion: reduce:点击直接落到成功终帧——不收缩、不旋转、对勾已完整——停留成功时长后复位;失败分支显示静态红叉。
- 键盘与无障碍:focus-visible 描边 2px #C9F45A、外扩 3px;aria-busy 跟随加载阶段,aria-live="polite" 的状态文本播报加载 / 成功 / 失败;按钮的可访问名称全程不变。
- 卸载时清理:清掉未触发的 timeout,停掉旋转动画,销毁后不要有任何状态更新。
禁止
- 不要用动画改变 width / height / padding / top / left / margin / box-shadow——按钮盒锁死 236px × 54px。
- 动画期间不要设置 disabled,也不要用它当阶段守卫。
- 不要嵌套 setTimeout 或用 setInterval 推进阶段;每个阶段一个 timeout,且必须清理。
- 不要跳过或缩短固定的 760ms 加载窗——它是契约的一部分,不是可调参数。
- 除上面的 props 外不要硬编码任何时长;所有时长都从 props 读取,缺省回落到文档给出的默认值。
- 不要改变交互语义、可访问名称或请求发起的时机;一次点击仍然只发一次请求。
- 不要留 TODO、不要写 // 其余代码、不要用 any、不要 console.log、不要留注释掉的实验代码。
- 不要把组件包进页面、布局、演示壳或 Provider。
- 不要改动全局 CSS、Tailwind 配置或上面文件之外的任何文件。
- 除 Motion 外不要引入任何动画库,更不要用 GSAP。
验收 —— 回答前逐项自查
1. 四段时长与 breathe 开关全部由 props 驱动,默认值严格等于 spec:300 / 280 / 460 / 900 ms,宽度 236px、高度 54px。
2. 在 60–80ms 采样点都能看到严格处于起止之间的中间帧:收起阶段标签透明度介于 1 与 0,成功阶段对勾描边介于 0 与 1,没有瞬跳。
3. 回到 idle 的结束帧没有残留:标签恢复 opacity 1、scale 1,虚线环与对勾层已卸载,按钮上没有遗留的行内 transform。
4. 整个三段过程中按钮父容器的 boundingBox 完全不变(宽度锁在 236px),只有按钮内部的 transform 与颜色在动,页面不抖动。
5. prefers-reduced-motion 下点击后 ≤1ms 直接落到最终态:不收缩、不旋转、对勾已完整画出,停留 successMs 后复位。
6. 键盘可完成整个操作:按钮可聚焦,Enter/Space 触发提交,动画期间焦点不丢失、focus-visible 描边可见,aria-busy 与 aria-live 正确播报状态。
7. 零 console 报错;卸载或复位后所有 timeout 与动画都被清理,没有遗留的定时器或动画帧。
输出
- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。
- 如果缺少必要依赖,先停下来问我,不要擅自安装。
- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { motion, useReducedMotion } from "motion/react";
import type { Locale } from "@/lib/i18n";
import { SUBMIT } from "@/content/effects/submit-button/constants";
type Phase = "idle" | "shrinking" | "loading" | "success" | "error";
export type SubmitButtonProps = {
/** label-out duration, ms @default 300 */
shrinkMs?: number;
/** check stroke duration, ms @default 280 */
checkMs?: number;
/** landing overshoot duration, ms @default 460 */
bounceMs?: number;
/** how long the success frame is held, ms @default 900 */
successMs?: number;
/** pulse the loading ring @default true */
breathe?: boolean;
/** resolve the submit as a failure instead of a success */
simulateError?: boolean;
locale?: Locale;
className?: string;
/** fired once the success / error frame is reached */
onSettled?: (result: "success" | "error") => void;
};
const RING_RADIUS = (SUBMIT.ring.size - SUBMIT.ring.stroke) / 2;
const RING_CENTER = SUBMIT.ring.size / 2;
export function SubmitButton({
shrinkMs = SUBMIT.defaults.shrinkMs,
checkMs = SUBMIT.defaults.checkMs,
bounceMs = SUBMIT.defaults.bounceMs,
successMs = SUBMIT.defaults.successMs,
breathe = SUBMIT.defaults.breathe,
simulateError = false,
locale = "zh",
className = "",
onSettled,
}: SubmitButtonProps) {
const reduced = useReducedMotion();
const instant = reduced === true;
const [phase, setPhase] = useState<Phase>("idle");
const timer = useRef<number | null>(null);
const settled = useRef(onSettled);
settled.current = onSettled;
useEffect(() => {
if (phase === "shrinking") {
timer.current = window.setTimeout(() => setPhase("loading"), shrinkMs);
} else if (phase === "loading") {
timer.current = window.setTimeout(() => {
setPhase(simulateError ? "error" : "success");
settled.current?.(simulateError ? "error" : "success");
}, SUBMIT.timeline.loadingMs);
} else if (phase === "success" || phase === "error") {
timer.current = window.setTimeout(
() => setPhase("idle"),
phase === "success" ? successMs : SUBMIT.timeline.errorMs,
);
}
return () => {
if (timer.current !== null) {
window.clearTimeout(timer.current);
timer.current = null;
}
};
}, [phase, shrinkMs, successMs, simulateError]);
const run = useCallback(() => {
if (phase !== "idle") return;
if (instant) {
const result = simulateError ? "error" : "success";
setPhase(result);
settled.current?.(result);
return;
}
setPhase("shrinking");
}, [phase, instant, simulateError]);
const label = locale === "en" ? "Submit" : "提交";
const statusText =
phase === "loading"
? locale === "en"
? "Submitting"
: "正在提交"
: phase === "success"
? locale === "en"
? "Submitted"
: "提交成功"
: phase === "error"
? locale === "en"
? "Submit failed"
: "提交失败"
: "";
const colors =
phase === "success"
? {
backgroundColor: SUBMIT.success.background,
borderColor: SUBMIT.success.border,
color: SUBMIT.success.color,
}
: phase === "error"
? {
backgroundColor: SUBMIT.failure.background,
borderColor: SUBMIT.failure.border,
color: SUBMIT.failure.color,
}
: {
backgroundColor: SUBMIT.idle.background,
borderColor: SUBMIT.idle.border,
color: SUBMIT.idle.color,
};
return (
<motion.button
type="button"
tabIndex={0}
onClick={run}
aria-busy={phase === "loading"}
aria-label={locale === "en" ? "Submit form" : "提交表单"}
data-phase={phase}
className={`relative inline-flex select-none items-center justify-center overflow-hidden border outline-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#C9F45A] ${className}`}
style={{
width: SUBMIT.button.width,
height: SUBMIT.button.height,
borderRadius: SUBMIT.button.radius,
fontSize: SUBMIT.button.fontSize,
fontWeight: SUBMIT.button.fontWeight,
letterSpacing: SUBMIT.button.letterSpacing,
paddingInline: SUBMIT.button.paddingX,
boxShadow: phase === "success" ? SUBMIT.success.glow : "none",
}}
animate={{
...colors,
scale: phase === "success" ? [...SUBMIT.success.scale] : 1,
}}
transition={
instant
? { duration: 0 }
: {
backgroundColor: { duration: 0.18 },
borderColor: { duration: 0.18 },
color: { duration: 0.18 },
scale:
phase === "success"
? { duration: bounceMs / 1000, times: [0, 0.5, 1], ease: "easeOut" }
: { duration: 0.18 },
}
}
whileHover={
phase === "idle" && !instant
? { borderColor: SUBMIT.idle.hoverBorder }
: undefined
}
whileTap={phase === "idle" && !instant ? { scale: 0.985 } : undefined}
>
<motion.span
className="pointer-events-none absolute inset-0 flex items-center justify-center gap-1.5"
animate={{
opacity: phase === "idle" ? 1 : 0,
scale: phase === "idle" || instant ? 1 : 0.82,
}}
transition={
instant
? { duration: 0 }
: { duration: shrinkMs / 1000, ease: "easeOut" }
}
>
{label}
</motion.span>
{phase === "loading" ? (
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
initial={{ opacity: 0 }}
animate={
breathe
? { opacity: [1, SUBMIT.breathe.opacityLow, 1], scale: [1, SUBMIT.breathe.scale, 1] }
: { opacity: 1, scale: 1 }
}
transition={
breathe
? { duration: SUBMIT.breathe.ms / 1000, repeat: Infinity, ease: "easeInOut" }
: { duration: 0.12 }
}
>
<motion.svg
width={SUBMIT.ring.size}
height={SUBMIT.ring.size}
viewBox={`0 0 ${SUBMIT.ring.size} ${SUBMIT.ring.size}`}
fill="none"
initial={{ rotate: 0 }}
animate={{ rotate: 360 }}
transition={{
rotate: {
duration: SUBMIT.timeline.spinMs / 1000,
repeat: Infinity,
ease: "linear",
},
}}
>
<circle
cx={RING_CENTER}
cy={RING_CENTER}
r={RING_RADIUS}
stroke={SUBMIT.ring.track}
strokeWidth={SUBMIT.ring.stroke}
/>
<circle
cx={RING_CENTER}
cy={RING_CENTER}
r={RING_RADIUS}
stroke="#c9f45a"
strokeWidth={SUBMIT.ring.stroke}
strokeLinecap="round"
strokeDasharray={`${SUBMIT.ring.dash} ${SUBMIT.ring.gap}`}
/>
</motion.svg>
</motion.span>
) : null}
{phase === "success" ? (
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
initial={{ opacity: instant ? 1 : 0 }}
animate={{ opacity: 1 }}
transition={{ duration: instant ? 0 : 0.1 }}
>
<svg
width={SUBMIT.check.size}
height={SUBMIT.check.size}
viewBox={`0 0 ${SUBMIT.check.viewBox} ${SUBMIT.check.viewBox}`}
fill="none"
>
<motion.path
d="M5.2 12.4 9.9 17.1 18.8 7.4"
stroke="currentColor"
strokeWidth={SUBMIT.check.stroke}
strokeLinecap="round"
strokeLinejoin="round"
initial={{ pathLength: instant ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: instant ? 0 : checkMs / 1000, ease: "easeOut" }}
/>
</svg>
</motion.span>
) : null}
{phase === "error" ? (
<motion.span
aria-hidden="true"
className="pointer-events-none absolute inset-0 flex items-center justify-center"
initial={{ opacity: instant ? 1 : 0, scale: instant ? 1 : 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: instant ? 0 : 0.14, ease: "easeOut" }}
>
<svg
width={SUBMIT.error.size}
height={SUBMIT.error.size}
viewBox={`0 0 ${SUBMIT.check.viewBox} ${SUBMIT.check.viewBox}`}
fill="none"
stroke="currentColor"
strokeWidth={SUBMIT.error.stroke}
strokeLinecap="round"
>
<path d="M7 7 17 17" />
<path d="M17 7 7 17" />
</svg>
</motion.span>
) : null}
<span className="sr-only" aria-live="polite">
{statusText}
</span>
</motion.button>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
点下按钮后它不会立刻变成 loading,而是分三段变形:文字在 300ms 内淡出并缩小,按钮宽度从始至终锁在 236px;接着出现一圈虚线环并匀速旋转 900ms 一圈,开启呼吸时整环以 900ms 的节奏一收一放;760ms 的固定加载窗结束后,按钮底色转绿,对勾用描边动画在 280ms 内画出来,同时按钮在 460ms 内轻微过冲落定。成功态停留 900ms 后复位;失败分支显示红色叉号,900ms 后回到可点击状态。演示面板可以勾选“模拟提交失败”来走错误分支。
- 收起时长
- 300 ms
- 对勾描边时长
- 280 ms
- 落定回弹时长
- 460 ms
- 成功停留时长
- 900 ms
- 加载环呼吸
- 开启
- 按钮占位
- 236 × 54 px
- 完整循环
- 1960 ms
- 固定加载窗
- 760 ms
- 对勾 / 落定
- 280 / 460 ms
- 环旋转一圈
- 900 ms
- 失败分支
- 1960 ms
- 呼吸
- 900 ms · 1.055×
验收清单
提示词要求 AI 在输出末尾逐条自检。
- 四段时长与 `breathe` 开关全部由 props 驱动,默认值严格等于 spec:300 / 280 / 460 / 900 ms,宽度 236px、高度 54px。
- 在 60–80ms 采样点都能看到严格处于起止之间的中间帧:收起阶段标签透明度介于 1 与 0,成功阶段对勾描边介于 0 与 1,没有瞬跳。
- 回到 idle 的结束帧没有残留:标签恢复 opacity 1、scale 1,虚线环与对勾层已卸载,按钮上没有遗留的行内 transform。
- 整个三段过程中按钮父容器的 boundingBox 完全不变(宽度锁在 236px),只有按钮内部的 transform 与颜色在动,页面不抖动。
- prefers-reduced-motion 下点击后 ≤1ms 直接落到最终态:不收缩、不旋转、对勾已完整画出,停留 successMs 后复位。
- 键盘可完成整个操作:按钮可聚焦,Enter/Space 触发提交,动画期间焦点不丢失、focus-visible 描边可见,aria-busy 与 aria-live 正确播报状态。
- 零 console 报错;卸载或复位后所有 timeout 与动画都被清理,没有遗留的定时器或动画帧。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。