Spring 还是 Ease-out:按动作的语义选
两种曲线都能让界面停下来。区别在于,spring 会保留受力感和可中断性,ease-out 更适合明确、克制的到达。
Spring 与 ease-out 的选择从动作语义开始:直接操控、可中断的对象需要连续速度与受力感;明确的确认、进入和结果交接需要可预测的抵达。用同一状态比较两种节奏,界面会更容易读懂。
先问这是不是一个可被拖拽、打断或反向的对象;再判断它需要物理感还是清晰的终点。
实施路径
- 01
连续输入用 spring
拖拽、抽屉和可直接操控的对象,需要速度能自然接上下一次输入。
- 02
明确反馈用 ease-out
保存、复制和轻量进入需要迅速抵达结果,过冲容易抢走信息焦点。
- 03
用同一状态做比较
保持位移、尺寸和时长接近,只改曲线,才能看清速度感的差异。
先判断动作在解释什么
曲线名称很容易把讨论带到审美偏好,用户真正感受到的是对象怎样回应自己的输入。先给动作写一句职责:侧栏跟随拖拽后回到停靠位,卡片被选中后表达当前焦点,保存按钮确认请求已收到,菜单在触发按钮附近抵达可读位置。职责不同,速度曲线承担的意义也不同。持续受手势影响、可能被再次抓住、方向可能反转的对象,需要保留当前位置和速度之间的连续关系;一次性确认、短暂进入、需要马上让出注意力的结果,则需要清晰而克制的终点。这个判断比先问“用哪条贝塞尔曲线”更可靠。
状态身份是第二个判断点。同一个抽屉从关闭到打开,用户能追踪到同一块内容在移动,因此可以使用带有重量感的收束。保存前后的按钮虽然占据同一位置,语义已经从“可执行”变成“处理中”再变成“完成”,重点是读清文字和结果,过冲会分散注意力。列表排序中的卡片既有身份连续性,也有大量信息需要阅读,适合让真实位置先稳定,再给很小的强调。把对象身份、用户输入和最终阅读目标列在设计说明里,设计、产品和工程就能用同一份理由选择曲线。
弹簧为连续输入保留速度
弹簧适合把对象看作正在被推动的实体。拖拽卡片、拉开抽屉、调整底部面板、拖动媒体进度和移动画布元素时,用户的手势已经提供了方向、距离和速度。松手后,弹簧可以把这些信息带入目标位置,让对象自然收束;下一次触摸或鼠标输入到来时,又可以从当前速度接手。这个连续性让人感觉界面持续响应自己,每次输入都从当前状态自然延续。弹簧需要服务操控感,因此目标位置、速度传递和取消机制应与手势状态放在同一个组件契约里。
调参可以从三项关系开始理解。刚度决定目标拉回对象的力度,数值提高时抵达更快;阻尼决定速度如何被吸收,数值提高时回弹更快收束;质量影响同一外力下的响应节奏。先固定位移和目标,再一次只改一个维度,观察对象是否仍然显得可控。导航抽屉通常需要较高阻尼和紧凑收束,避免遮挡内容时反复摆动;可拖拽卡片可以允许一小段柔和回弹,用来确认落点。参数的好坏取决于动作的后果、出现频率和屏幕密度,每个组件都应保存自己的合理区间,供团队以动作语义为依据维护。
Ease-out 让明确结果迅速抵达
ease-out 的价值在于开始阶段有足够速度,抵达前逐渐放慢,让用户先看到变化已经发生,再有时间读清最终状态。它适合菜单、提示、轻量卡片进入、复制确认、保存结果和按钮按压恢复。这些动作通常有清楚的起点与终点,也很少需要继承上一段手势速度。把持续时间控制在内容与任务需要的范围内,视觉就会显得果断:按钮反馈常在 100 到 180ms 内收住,局部状态交接可以略长,复杂层级的进入再根据阅读量增加。每一段时间都应帮助用户读到结果或找到下一步。
一段 ease-out 动画也需要保护空间关系。菜单从触发按钮附近出现时,起始透明度、很小的位移和稳定的最终边界足以说明来源;成功提示在保存按钮旁边淡入时,文字、状态色和图标共同表达完成。过大的缩放、旋转和多次弹跳会让一个简单结果变得比内容本身更醒目。将曲线应用到 opacity 与 transform 上,可以保持布局稳定,也让减弱动效模式容易缩短或直接交接。ease-out 在这里是一种信息排序工具:先确认动作,再呈现结果,最后让用户回到下一次操作。
中断、反向与连点决定实现方式
曲线选择离不开中断策略。用户可能在抽屉打开到一半时再次拖回去,在排序动画尚未结束时移动另一张卡片,或在保存结果出现前再次编辑字段。组件需要知道新输入到来后保留什么:当前视觉位置、当前速度、目标状态、焦点和可访问性文案。直接操控的 spring 应立即取消旧目标并接住当前值;一次性 ease-out 反馈可以被新状态替换,让旧结果淡出或直接结束。无论采用哪条路径,状态源都应优先于计时器,避免过期回调在用户已经改变意图后重新覆盖界面。
浏览器与动画库的 API 也会影响交接质量。CSS transition 很适合简洁、可预期的 ease-out 状态变化;Web Animations API 能提供取消、完成和播放状态,适合需要由状态机统一管理的短动作;物理 spring 则常需要每帧积分或库提供的速度模型。选择实现时,先检查组件是否要接收拖拽速度、是否需要在任何时刻反向、是否有多个对象共享同一状态。把取消函数、目标值和完成条件显式保留,测试才能覆盖快速连点、方向反转和路由切换。实现清楚后,曲线才会在真实使用中保持它原本的语义。
在同一状态上比较并完成验收
比较 spring 与 ease-out 时,应先锁定位移、对象尺寸、触发位置和最终状态,只改变速度模型。这样团队能观察到真正的差异:弹簧是否让连续操控更自然,ease-out 是否让结果更容易阅读。若同时改了距离、颜色、阴影和时长,讨论很快会失去因果关系。建议在组件文档中保留一个可切换的对照演示,并展示完整产品瞬间,例如拖开抽屉后选择导航项、保存后看到确认并继续编辑。原子动效的手感在流程里接受检验,才能判断它是否帮助用户理解当前动作。
验收还要覆盖减弱动效和可访问性。系统偏好开启后,侧栏仍然到达正确位置,焦点仍然进入可操作区域,保存状态仍然从“保存中”变成“已保存”;只需减少大幅位移、反复回弹和装饰性旋转。检查键盘触发、触摸拖拽、屏幕较窄时的遮挡、连续输入和页面离开时的取消。最后记录每个组件的选择理由:动作服务什么意义、允许几次回弹、最大时长、如何中断、低动态路径保留什么。这样的记录会让后续设计保持一致,也让用户在不同页面遇到可预测的动效语言。
Spring 与 ease-out 选择清单
- 先写清动作要解释的意义明确对象身份、用户输入、最终阅读目标和下一步操作。
- 直接操控保留位置与速度连续性拖拽、抽屉和画布对象拥有目标、取消和接管新输入的策略。
- 明确结果使用紧凑抵达菜单、保存和复制确认以短时 ease-out 支持阅读与继续操作。
- 比较时只改变速度模型锁定位移、尺寸、最终状态和触发位置,让差异可被直接观察。
- 中断与低动态路径已经验收覆盖快速连点、反向、键盘、触摸、路由离开与系统偏好切换。
案例:抽屉用 spring 接住拖拽,保存提示用 ease-out 交接
抽屉在拖拽松手后延续当前速度并收束到开合位置;保存提示在原操作附近快速抵达完成状态。
<section class="curve-demo">
<label>
Drawer position
<input type="range" min="-240" max="0" value="-240" data-drawer-range>
</label>
<button type="button" data-drawer-toggle>Open drawer</button>
<div class="drawer-stage">
<aside class="drawer" data-drawer aria-label="Settings drawer">Settings stay attached to the drag position.</aside>
</div>
<button type="button" data-save>Save settings</button>
<p class="save-status" data-save-status data-state="idle" role="status" aria-live="polite">Ready to save.</p>
</section>
<style>
.curve-demo { display: grid; gap: 12px; max-width: 32rem; }
.drawer-stage { overflow: hidden; min-height: 4rem; border: 1px solid currentColor; }
.drawer { width: 14rem; min-height: 4rem; padding: 12px; background: Canvas; }
.save-status { opacity: 0; transform: translateY(6px); transition: opacity 160ms ease-out, transform 160ms ease-out; }
.save-status[data-state="saving"], .save-status[data-state="saved"] { opacity: 1; transform: translateY(0); }
@media (prefers-reduced-motion: reduce) {
.save-status { transition-duration: 1ms; }
}
</style>
<script>
const reducedMotionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
const drawer = document.querySelector("[data-drawer]");
const drawerRange = document.querySelector("[data-drawer-range]");
const drawerToggle = document.querySelector("[data-drawer-toggle]");
const saveButton = document.querySelector("[data-save]");
const saveStatus = document.querySelector("[data-save-status]");
let drawerPosition = -240;
let drawerVelocity = 0;
let drawerTarget = -240;
let lastInputTime = performance.now();
let cancelDrawerSpring = () => {};
function springTo(element, from, target, releaseVelocity = 0, onUpdate = () => {}) {
let position = from;
let velocity = releaseVelocity;
let previous = performance.now();
let frame = 0;
let finished = false;
const stiffness = 340;
const damping = 34;
const update = (nextPosition, nextVelocity) => {
position = nextPosition;
velocity = nextVelocity;
element.style.transform = "translateX(" + position + "px)";
onUpdate(position, velocity);
};
const cleanup = () => {
reducedMotionQuery.removeEventListener("change", onReducedMotionChange);
};
function onReducedMotionChange(event) {
if (event.matches) finish();
}
const finish = () => {
if (finished) return;
finished = true;
window.cancelAnimationFrame(frame);
update(target, 0);
cleanup();
};
const cancel = () => {
if (finished) return;
finished = true;
window.cancelAnimationFrame(frame);
onUpdate(position, velocity);
cleanup();
};
if (reducedMotionQuery.matches) {
finish();
return () => {};
}
reducedMotionQuery.addEventListener("change", onReducedMotionChange);
const tick = (now) => {
if (finished) return;
const dt = Math.min((now - previous) / 1000, 0.032);
previous = now;
velocity += (-stiffness * (position - target) - damping * velocity) * dt;
position += velocity * dt;
update(position, velocity);
if (Math.abs(velocity) < 0.1 && Math.abs(position - target) < 0.1) {
finish();
return;
}
frame = window.requestAnimationFrame(tick);
};
frame = window.requestAnimationFrame(tick);
return cancel;
}
function setDrawerPosition(position, velocity = 0) {
drawerPosition = Math.max(-240, Math.min(0, position));
drawerVelocity = velocity;
drawer.style.transform = "translateX(" + drawerPosition + "px)";
drawerRange.value = String(Math.round(drawerPosition));
}
function settleDrawer(target = drawerPosition > -120 ? 0 : -240) {
cancelDrawerSpring();
drawerTarget = target;
drawerToggle.textContent = target === 0 ? "Close drawer" : "Open drawer";
cancelDrawerSpring = springTo(drawer, drawerPosition, target, drawerVelocity, (position, velocity) => {
drawerPosition = position;
drawerVelocity = velocity;
drawerRange.value = String(Math.round(Math.max(-240, Math.min(0, position))));
});
}
drawerRange.addEventListener("input", () => {
cancelDrawerSpring();
const now = performance.now();
const nextPosition = Number(drawerRange.value);
const nextVelocity = (nextPosition - drawerPosition) / Math.max((now - lastInputTime) / 1000, 0.016);
lastInputTime = now;
drawerTarget = nextPosition > -120 ? 0 : -240;
drawerToggle.textContent = drawerTarget === 0 ? "Close drawer" : "Open drawer";
setDrawerPosition(nextPosition, nextVelocity);
});
drawerRange.addEventListener("pointerdown", () => {
cancelDrawerSpring();
lastInputTime = performance.now();
});
drawerRange.addEventListener("change", () => settleDrawer());
drawerToggle.addEventListener("click", () => {
settleDrawer(drawerTarget === 0 ? -240 : 0);
});
async function saveSettings() {
if (saveButton.disabled) return;
saveButton.disabled = true;
saveStatus.dataset.state = "saving";
saveStatus.textContent = "Saving settings.";
await new Promise((resolve) => window.setTimeout(resolve, 160));
saveStatus.dataset.state = "saved";
saveStatus.textContent = "Settings saved.";
saveButton.disabled = false;
}
saveButton.addEventListener("click", saveSettings);
setDrawerPosition(drawerPosition);
</script>`springTo` 接收松手瞬间的 releaseVelocity,并把它作为积分器初始速度,因此新的拖拽、反向操作或路由离开都能立即接管旧动画。系统减弱动效偏好通过 change 事件立即落在目标位置,并在完成与取消时清理监听;保存提示使用短时 ease-out,文案和状态仍由同一业务状态驱动,方便用户读到完成结果后继续操作。