果冻开关
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 ✓,未通过的项用 ✗ 并附一句原因。
"use client";
import { animate, motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from "react";
import { JELLY } from "@/content/effects/jelly-toggle/constants";
export type JellyToggleProps = {
/** controlled state; omit to let the switch manage itself */
checked?: boolean;
/** initial state for the uncontrolled switch */
defaultChecked?: boolean;
onChange?: (next: boolean) => void;
/** accessible name for the switch */
label: string;
/** release spring visual duration in ms */
duration?: number;
/** scaleY squash as a fraction of the horizontal stretch */
stretch?: number;
/** cap, in px, put on the horizontal stretch while dragging */
maxStretch?: number;
/** movement, in px, below which a press counts as a click */
dragSlop?: number;
className?: string;
};
type DragState = {
pointerId: number;
startClientX: number;
startX: number;
lastClientX: number;
lastTime: number;
/** px / ms */
velocity: number;
dx: number;
moved: boolean;
};
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
/**
* Drag-to-toggle switch: the knob follows the pointer, stretches with the
* drag like jelly, and springs back to scale 1 when released.
*/
export function JellyToggle({
checked,
defaultChecked = false,
onChange,
label,
duration = JELLY.defaults.duration,
stretch = JELLY.defaults.stretch,
maxStretch = JELLY.defaults.maxStretch,
dragSlop = JELLY.defaults.dragSlop,
className = "",
}: JellyToggleProps) {
const reduceMotion = useReducedMotion() ?? false;
const [internalOn, setInternalOn] = useState(defaultChecked);
const isControlled = checked !== undefined;
const on = isControlled ? checked : internalOn;
const knobRef = useRef<HTMLSpanElement>(null);
const dragRef = useRef<DragState | null>(null);
const dragAnimationRef = useRef<ReturnType<typeof animate> | null>(null);
const releaseAnimationRef = useRef<ReturnType<typeof animate> | null>(null);
const firstRenderRef = useRef(true);
const lastOnRef = useRef(on);
const skipSyncRef = useRef(false);
const [dragDirection, setDragDirection] = useState<-1 | 0 | 1>(0);
// `useReducedMotion` only resolves on the client. Keep the first render
// identical to the server output so reduced-motion users do not get a
// hydration attribute mismatch on the inline `transition` declarations.
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const instantColors = reduceMotion && mounted;
const targetXFor = useCallback(
(value: boolean) => (value ? JELLY.track.travel : 0),
[],
);
const readCurrentX = useCallback(() => {
const knob = knobRef.current;
if (!knob) return targetXFor(on);
const matrix = getComputedStyle(knob).transform;
if (!matrix || matrix === "none") return targetXFor(on);
try {
return new DOMMatrixReadOnly(matrix).m41;
} catch {
return targetXFor(on);
}
}, [on, targetXFor]);
const settle = useCallback(
(desiredOn: boolean, velocityPxPerMs: number) => {
const knob = knobRef.current;
if (!knob) return;
dragAnimationRef.current?.stop();
releaseAnimationRef.current?.stop();
const springDuration = Math.max(
JELLY.minVisualDuration,
duration / 1000,
);
const springBounce = clamp(
stretch * JELLY.drag.releaseBounce,
0,
1,
);
const spring = {
type: "spring" as const,
visualDuration: springDuration,
bounce: springBounce,
};
const velocityPxPerSecond = clamp(
velocityPxPerMs * 1000,
-JELLY.drag.maxVelocity,
JELLY.drag.maxVelocity,
);
releaseAnimationRef.current = animate(
knob,
{ x: targetXFor(desiredOn), scaleX: 1, scaleY: 1 },
reduceMotion
? { duration: 0, onComplete: () => setDragDirection(0) }
: {
x: { ...spring, velocity: velocityPxPerSecond },
scaleX: spring,
scaleY: spring,
onComplete: () => setDragDirection(0),
},
);
},
[duration, stretch, reduceMotion, targetXFor],
);
const paintDrag = useCallback(
(startX: number, dx: number) => {
const knob = knobRef.current;
if (!knob) return;
const x = clamp(startX + dx, 0, JELLY.track.travel);
if (reduceMotion) {
animate(knob, { x, scaleX: 1, scaleY: 1 }, { duration: 0 });
return;
}
const stretchedPx = Math.min(maxStretch, Math.abs(dx));
const ratio = stretchedPx / JELLY.knob.size;
dragAnimationRef.current?.stop();
dragAnimationRef.current = animate(
knob,
{
x,
scaleX: 1 + ratio,
scaleY: 1 - stretch * ratio,
},
{ duration: 0 },
);
},
[maxStretch, stretch, reduceMotion],
);
const toggleFromClick = useCallback(() => {
const next = !on;
if (next !== on) {
skipSyncRef.current = true;
if (!isControlled) setInternalOn(next);
onChange?.(next);
}
settle(next, 0);
}, [on, isControlled, onChange, settle]);
const handlePointerDown = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (event.pointerType === "mouse" && event.button !== 0) return;
const startX = readCurrentX();
dragRef.current = {
pointerId: event.pointerId,
startClientX: event.clientX,
startX,
lastClientX: event.clientX,
lastTime: performance.now(),
velocity: 0,
dx: 0,
moved: false,
};
releaseAnimationRef.current?.stop();
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// Safari can refuse capture on a lost pointer; the drag simply degrades.
}
event.currentTarget.focus({ preventScroll: true });
event.preventDefault();
};
const handlePointerMove = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
const dx = event.clientX - drag.startClientX;
drag.dx = dx;
const now = performance.now();
const dt = now - drag.lastTime;
if (dt > 0) {
drag.velocity = (event.clientX - drag.lastClientX) / dt;
}
drag.lastClientX = event.clientX;
drag.lastTime = now;
if (!drag.moved) {
if (Math.abs(dx) <= dragSlop) return;
drag.moved = true;
}
const nextDirection: -1 | 0 | 1 = dx > 0 ? 1 : dx < 0 ? -1 : 0;
setDragDirection((previous) =>
previous === nextDirection ? previous : nextDirection,
);
paintDrag(drag.startX, dx);
};
const finishDrag = useCallback(
(cancel: boolean) => {
const drag = dragRef.current;
if (!drag) return;
dragRef.current = null;
try {
knobRef.current?.releasePointerCapture?.(drag.pointerId);
} catch {
// the pointer may already be gone
}
if (!cancel && drag.moved && Math.abs(drag.dx) > dragSlop) {
const flicking =
Math.abs(drag.velocity) * 1000 > JELLY.drag.flickThreshold;
const nextOn = flicking
? drag.velocity > 0
: drag.startX + drag.dx > JELLY.track.travel / 2;
if (nextOn !== on) {
skipSyncRef.current = true;
if (!isControlled) setInternalOn(nextOn);
onChange?.(nextOn);
}
settle(nextOn, drag.velocity);
return;
}
if (!cancel && !drag.moved) {
toggleFromClick();
return;
}
settle(on, 0);
},
[dragSlop, on, isControlled, onChange, settle, toggleFromClick],
);
const handlePointerUp = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
finishDrag(false);
};
const handlePointerCancel = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = dragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
finishDrag(true);
};
const handleLostCapture = () => {
if (dragRef.current) finishDrag(true);
};
const handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
if (event.detail === 0) toggleFromClick();
};
useEffect(() => {
const knob = knobRef.current;
if (!knob) return;
if (firstRenderRef.current) {
firstRenderRef.current = false;
lastOnRef.current = on;
animate(
knob,
{ x: targetXFor(on), scaleX: 1, scaleY: 1 },
{ duration: 0 },
);
return;
}
if (lastOnRef.current === on) return;
lastOnRef.current = on;
if (skipSyncRef.current) {
skipSyncRef.current = false;
return;
}
settle(on, 0);
}, [on, settle, targetXFor]);
useEffect(
() => () => {
dragAnimationRef.current?.stop();
releaseAnimationRef.current?.stop();
dragRef.current = null;
},
[],
);
const origin =
dragDirection === 1
? "left center"
: dragDirection === -1
? "right center"
: "center";
return (
<button
type="button"
role="switch"
aria-checked={on}
aria-label={label}
data-state={on ? "on" : "off"}
data-dragging={dragDirection !== 0 ? "true" : "false"}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerCancel={handlePointerCancel}
onLostPointerCapture={handleLostCapture}
onClick={handleClick}
onDragStart={(event) => event.preventDefault()}
className={`relative inline-flex shrink-0 touch-pan-y select-none items-center border-0 bg-transparent p-0 outline-none focus-visible:outline-2 focus-visible:outline-offset-[3px] focus-visible:outline-[#c9f45a] ${
dragDirection === 0 ? "cursor-grab" : "cursor-grabbing"
} ${className}`}
style={{
width: JELLY.track.width,
height: JELLY.track.height,
borderRadius: JELLY.track.radius,
}}
>
<span
aria-hidden="true"
className="pointer-events-none absolute inset-0"
style={{
borderRadius: JELLY.track.radius,
borderStyle: "solid",
borderWidth: JELLY.track.border,
backgroundColor: on
? JELLY.trackOn.background
: JELLY.trackOff.background,
borderColor: on ? JELLY.trackOn.border : JELLY.trackOff.border,
transition: instantColors
? "none"
: `background-color ${JELLY.colorMs}ms ease, border-color ${JELLY.colorMs}ms ease`,
}}
/>
<motion.span
ref={knobRef}
aria-hidden="true"
className="pointer-events-none absolute block"
style={{
width: JELLY.knob.size,
height: JELLY.knob.size,
left: JELLY.knob.inset,
top: JELLY.knob.inset,
borderRadius: JELLY.knob.radius,
transformOrigin: origin,
backgroundColor: on ? JELLY.knobOn.background : JELLY.knobOff.background,
boxShadow: on ? JELLY.knob.onShadow : JELLY.knob.shadow,
transition: instantColors
? "none"
: `background-color ${JELLY.colorMs}ms ease, box-shadow ${JELLY.colorMs}ms ease`,
}}
/>
</button>
);
}
构建时从真实文件读取 —— 这是你真的在看的那份代码,不是另写的示例。
技术规格
一个 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 在输出末尾逐条自检。
- 尺寸、行程与四个参数严格一致:轨道 104 × 48px、滑块 36px、行程 56px;切换结束后滑块精确停在 x = 0 或 56px;松手弹簧用 350ms,拖动拉伸的 `scaleX` 最大值与 `11px / 36px` 算出的结果一致,`scaleY` 压缩量与 stretch 成比例。
- 在松手后的 60–80ms 采样点,滑块 x 严格处于起点与终点之间、`scaleX` 介于 1 与峰值之间、`scaleY` 介于 1 与谷值之间,能看到连续弹簧帧而不是瞬跳。
- 结束帧无残留:`scaleX` / `scaleY` 精确回到 1,`x` 精确是 0 或 56,滑块没有遗留 `transform-origin` 偏移或内联 transform;不再有运行中的动画。
- 整个拖动、拉伸与回弹过程中按钮父容器的 boundingBox 完全不变(轨道宽 104px),只有滑块 transform 在动,页面不抖动、不出现滚动条跳动。
- prefers-reduced-motion 下拖动不产生拉伸,松手与键盘切换在 ≤1ms 内直接落到终态,同时 aria-checked 与颜色状态保持正确。
- 键盘可完成完整操作:Tab 聚焦后 Space / Enter 切换,role="switch" 与 aria-checked 同步,focus-visible 是 2px #C9F45A、外扩 3px,按钮始终可聚焦。
- 零 console 报错;pointerup / pointercancel / 卸载后 pointer capture 已释放,没有残留的监听、rAF 或动画。
待回归验证提示词与演示已同源生成;尚未在真实 Cursor / Claude Code 项目中做回归。
提示词如何还原这个效果
不是把效果描写一遍,而是把效果写成一份可执行的工程需求。
- 数字规格
- 每个像素、每个时间参数都来自同一个参数源——滑块改什么,提示词就写什么。
- 文件契约
- 只允许新建指定文件、指定依赖白名单,避免它顺手改你的全局样式或装一堆包。
- 验收清单
- 提示词末尾强制 AI 自检布局位移、键盘可用、reduced-motion 等 7 项。