返回效果库

果冻开关

Jelly Toggle

按住滑块左右拖,橡胶一样被拉长压扁,松手再弹到最近一侧

完整版:适合从零新建组件,规格、契约、验收全都在。

实时预览

指针交互

状态 · 关闭

按住滑块左右拖动:它会像果冻一样被拉长压扁,松手弹到最近一侧;轻点一下也能切换。

Tab 聚焦 · Space / Enter 切换

把指针移进这片区域触屏设备:直接在演示区域操作检测到「减少动态效果」,动效已降级

参数

边调边看,提示词同步更新。

角色

你是一名资深前端工程师,工作在现有的项目中,技术栈是 React 19 + TypeScript + Tailwind CSS v4 + Motion v14(motion 包)。只实现下面这一个微交互:不要加额外功能,不要顺手重构周围代码。

任务

实现一个果冻开关:轨道 104 × 48px,滑块 36px,行程固定 56px。按住滑块拖动时它跟手移动,同时像果冻一样被拉伸——scaleX 最多加宽 11px,scaleY 按 0.38 的比例压扁;松手后一条 bounce 为 0.15 的弹簧按甩动速度或半程位置决定落到哪一侧,并在 350ms 内把位置与形变一起弹定、形变精确回到 1。位移不超过 3px 的按下算点击,不产生拉伸直接切换。

文件契约

- 只新建一个文件:components/effects/JellyToggle.tsx。

- 依赖:项目已安装 motion@14,从 motion/react 引入 animate、motion、useReducedMotion。不要新增任何其他包。

- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。

实现要点

- 文件顶部加 "use client";checked / defaultChecked / onChange 同时支持受控与非受控,四个参数全部来自 props(默认值取自 constants)。

- 指针事件全部挂在真实 <button type="button" role="switch"> 上:onPointerDown 里 setPointerCapture(pointerId) 并记录起点,onPointerMove 用 clientX 差值计算 dx,onPointerUp / onPointerCancel 释放捕获并结算。

- 拖动期间用 animate(knob, { x, scaleX, scaleY }, { duration: 0 }) 直接跟手:x = clamp(startX + dx, 0, travel);拉伸像素 p = min(maxStretch, |dx|),scaleX = 1 + p / knobSize、scaleY = 1 - stretch * p / knobSize;transform-origin 设在拖动起始的拖尾边(向右拖用 left,向左拖用 right)。

- 松手结算:|velocity| > flickThreshold 时按速度方向翻面,否则按 x 是否越过 travel / 2 判断;随后 animate 用 { type: "spring", visualDuration: Math.max(minVisualDuration, duration / 1000), bounce: stretch * releaseBounce, velocity: clamp(velocity, ±maxVelocity) } 把 x、scaleX、scaleY 一起弹回终态。

- |dx| <= dragSlop 视为点击:不产生拉伸,直接切换并只用弹簧移动 x;键盘点击(event.detail === 0)走同一路径。指针事件要 preventDefault() 避免拖动时选中文本,按钮用 [touch-action:pan-y] 保留纵向滚动。

- prefers-reduced-motion: reduce:拖动只更新 x 不做拉伸,松手与键盘切换都在 ≤1ms 内跳到终态;卸载时停止所有 animate() 控制、释放 pointer capture,不留下动画。

规格 —— 精确数值,不许四舍五入或“优化”

- 轨道 104px × 48px,圆角 24px,1px 边框;滑块 36px、内边距 6px,因此行程恰好 56px(x = 0 ↔ 56px)。

- 按住滑块左右拖动:x = clamp(startX + dx, 0, 56),滑块实时跟手,绝不跳位。

- 拖动时拉伸:stretchPixels = min(11, |dx|),ratio = stretchPixels / 36;scaleX = 1 + ratio(峰值 1.31),scaleY = 1 − 0.38 × ratio(谷值 0.88)。transform-origin 设在拖尾边:向右拖用 left,向左拖用 right。

- 松手:|velocity| > 350px/s 时按速度方向翻面,否则按 x 是否越过 28px 判断;随后一条弹簧在 350ms 内把 x、scaleX、scaleY 一起弹回:visualDuration 0.35s、bounce 0.15(stretch × 0.4)、velocity 截断到 ±1400px/s,最后一帧形变精确为 1。

- 速度采样:只保留最后两次 pointermove 的 Δx / Δt(px/s)用于松手结算;不要累积整段手势历史,也不要用平均速度。

- 点击与拖动的边界:位移 ≤ 3px 视为点击;点击切换状态时不产生任何拉伸,只用弹簧把 x 送到另一侧。

- 颜色:关闭态轨道背景 rgba(237, 238, 240, 0.055)、边框 rgba(237, 238, 240, 0.14)、滑块 #d9dbdf;开启态轨道背景 rgba(201, 244, 90, 0.17)、边框 rgba(201, 244, 90, 0.48)、滑块 #c9f45a,在 220ms 内交叉渐变。滑块阴影固定:关闭 0 6px 16px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.35),开启 0 6px 18px rgba(201, 244, 90, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.55)。

- 只允许动画 transform、opacity、background-color、border-color、box-shadow;轨道尺寸绝不改变。

行为

- 文字状态机:idle(关闭) --pointerdown--> pressed;pressed --pointermove |dx| > 3px--> dragging(拉伸随 |dx| 变化);pressed --pointerup |dx| ≤ 3px--> 点击切换;dragging --pointerup--> settle(目标由速度 / 半程位置决定);dragging --pointercancel 或丢失捕获--> settle(回到当前一侧);任意状态 --reduced motion--> 立即终态。

- 松手后整段回弹在 350ms(弹簧 visualDuration)内完成,最后一帧精确是 x = 0 或 56px,且 scaleX = scaleY = 1。

- 回弹过程中再次按住拖动必须从滑块当前 x 与当前形变继续,不能从旧端点开始,也不要把动画排队。

- 受控与非受控都必须可用(checked + onChange 或 defaultChecked);开关状态只允许一个真相来源。

- 键盘与无障碍:根元素是真实 <button type="button" role="switch">;Space / Enter 切换(键盘 click 的 detail === 0);aria-checked 同步状态;focus-visible 为 2px #C9F45A、外扩 3px;控件永远可聚焦,绝不 disabled。

- prefers-reduced-motion: reduce:拖动只更新 x、不写 scaleX/scaleY;松手与键盘切换在 ≤1ms 内直接落终态、不跑弹簧,可访问状态仍然更新。

- 卸载或取消拖动时停止所有运行中的 animate() 控制并清空指针采样;销毁后不要有任何状态更新。

禁止

- 不要改变交互语义:点击与拖动必须清晰区分,点击绝不允许产生拉伸。

- 不要修改或去掉可访问名称与被播报的状态;不要给开关换名,也不要移除 aria-checked。

- 不要逐帧动画 width / height / top / left——滑块只靠 transform 移动。

- 不要链式 setTimeout,也不要在每次 pointermove 里另起定时器;松手只允许一条弹簧。

- 滑块上不允许残留 scale:最后一帧必须精确是 scaleX = scaleY = 1。

- 不要用 disabled、pointer-events: none 或屏蔽 dragstart 让控件无法聚焦。

- 不要叠加第二个动画驱动与弹簧抢同一属性;不要设置 touch-action: none(纵向滚动必须保留)。

- 除 duration 参数与文档默认值外不要硬编码任何时长;不要留 any、TODO 或注释掉的实验代码。

- 不要修改 package.json、全局 CSS、Tailwind 配置或任何其他已有文件。

- 除 Motion 外不要引入任何动画库,更不要用 GSAP。

验收 —— 回答前逐项自查

1. 尺寸、行程与四个参数严格一致:轨道 104 × 48px、滑块 36px、行程 56px;切换结束后滑块精确停在 x = 0 或 56px;松手弹簧用 350ms,拖动拉伸的 scaleX 最大值与 11px / 36px 算出的结果一致,scaleY 压缩量与 stretch 成比例。

2. 在松手后的 60–80ms 采样点,滑块 x 严格处于起点与终点之间、scaleX 介于 1 与峰值之间、scaleY 介于 1 与谷值之间,能看到连续弹簧帧而不是瞬跳。

3. 结束帧无残留:scaleX / scaleY 精确回到 1,x 精确是 0 或 56,滑块没有遗留 transform-origin 偏移或内联 transform;不再有运行中的动画。

4. 整个拖动、拉伸与回弹过程中按钮父容器的 boundingBox 完全不变(轨道宽 104px),只有滑块 transform 在动,页面不抖动、不出现滚动条跳动。

5. prefers-reduced-motion 下拖动不产生拉伸,松手与键盘切换在 ≤1ms 内直接落到终态,同时 aria-checked 与颜色状态保持正确。

6. 键盘可完成完整操作:Tab 聚焦后 Space / Enter 切换,role="switch" 与 aria-checked 同步,focus-visible 是 2px #C9F45A、外扩 3px,按钮始终可聚焦。

7. 零 console 报错;pointerup / pointercancel / 卸载后 pointer capture 已释放,没有残留的监听、rAF 或动画。

输出

- 用一个代码块输出完整文件内容,不要 diff、不要省略号,代码块之前不要解释。

- 如果缺少必要依赖,先停下来问我,不要擅自安装。

- 代码块之后最后输出一行:self-check: 1 ✓ · 2 ✓ · 3 ✓ · 4 ✓ · 5 ✓ · 6 ✓ · 7 ✓,未通过的项用 ✗ 并附一句原因。

与演示同源生成4,916 字符 · 1,366 约 tokens · 63 行
技术规格

一个 104px × 48px 的开关,滑块 36px、行程 56px 固定。按住滑块拖动时,滑块跟着指针走,并按拖动距离被拉长:水平方向最多增加 11px(scaleX 至多到 1.31),垂直方向按 0.38 的比例被压扁,像一块被拉扯的果冻。松手后弹簧按松手位置与速度决定落到哪一侧,并在 350ms 内带着轻微过冲弹回、形状复原;指针移动不超过 3px 时按点击处理,直接切换。Space / Enter 同样可以切换。

切换时长
350 ms
拉伸比例
0.38 ×
最大拉伸
11 px
点击判定容差
3 px
轨道 / 滑块
104 × 48 / 36 px
行程
56 px
拉伸峰值
scaleX 1.31 · scaleY 0.88
最大加宽
11 px
回弹弹簧
350 ms · bounce 0.15
速度上限
1400 px/s · flick 350 px/s
点击容差
3 px
验收清单

提示词要求 AI 在输出末尾逐条自检。

  1. 尺寸、行程与四个参数严格一致:轨道 104 × 48px、滑块 36px、行程 56px;切换结束后滑块精确停在 x = 0 或 56px;松手弹簧用 350ms,拖动拉伸的 `scaleX` 最大值与 `11px / 36px` 算出的结果一致,`scaleY` 压缩量与 stretch 成比例。
  2. 在松手后的 60–80ms 采样点,滑块 x 严格处于起点与终点之间、`scaleX` 介于 1 与峰值之间、`scaleY` 介于 1 与谷值之间,能看到连续弹簧帧而不是瞬跳。
  3. 结束帧无残留:`scaleX` / `scaleY` 精确回到 1,`x` 精确是 0 或 56,滑块没有遗留 `transform-origin` 偏移或内联 transform;不再有运行中的动画。
  4. 整个拖动、拉伸与回弹过程中按钮父容器的 boundingBox 完全不变(轨道宽 104px),只有滑块 transform 在动,页面不抖动、不出现滚动条跳动。
  5. prefers-reduced-motion 下拖动不产生拉伸,松手与键盘切换在 ≤1ms 内直接落到终态,同时 aria-checked 与颜色状态保持正确。
  6. 键盘可完成完整操作:Tab 聚焦后 Space / Enter 切换,role="switch" 与 aria-checked 同步,focus-visible 是 2px #C9F45A、外扩 3px,按钮始终可聚焦。
  7. 零 console 报错;pointerup / pointercancel / 卸载后 pointer capture 已释放,没有残留的监听、rAF 或动画。

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

提示词如何还原这个效果

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

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

React + Tailwind + Motion