返回效果库

提交按钮三段变形

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 ✓,未通过的项用 ✗ 并附一句原因。

与演示同源生成4,399 字符 · 1,222 约 tokens · 66 行
技术规格

点下按钮后它不会立刻变成 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 在输出末尾逐条自检。

  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 与动画都被清理,没有遗留的定时器或动画帧。

待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。

提示词如何还原这个效果

不是把效果描写一遍,而是把效果写成一份可执行的工程需求。

数字规格
每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
文件契约
只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
验收清单
提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。
完整版 / 中文

React + Tailwind + Motion