
Remix UI 的 Spring 物理动画 API 完全指南弹簧缓动迭代器、CSS 过渡与 Web Animations 集成【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remixspring是 Remix UI本仓库packages/ui动画模块中基于物理模型源自 SwiftUI 的 spring 数学的弹簧动画函数它返回一个携带 CSSlinear()缓动曲线的迭代器可同时服务于 CSS transition、Web Animations APIWAAPI与命令式 JavaScript 动画。本文以 packages/ui/docs/spring.md 为骨架结合 spring.ts 源码 与 spring.test.ts 测试完整讲解其 API、预设、参数语义与底层实现让读者可以立即在组件中写出带真实物理质感的过渡、入场/退场与拖拽回弹动画。为什么选择物理弹簧而不是固定缓动传统的 CSS 缓动函数ease-in、ease-out、cubic-bezier描述的是“时间到进度”的固定映射而弹簧动画模拟的是真实物理系统——质量、刚度stiffness与阻尼damping共同决定运动轨迹天然具备两个传统缓动难以表达的特性过冲overshoot欠阻尼弹簧会冲过目标值再回弹产生活泼的“bouncy”质感初始速度velocity可以延续手势的惯性例如拖拽松手后按当前速度继续滑向目标而不是从零开始。Remix UI 的spring正是为此设计它把弹簧参数换算成浏览器可直接消费的形式——一个 CSSlinear()函数现代浏览器对linear()的支持因此既能当作 transition 的时间函数也能迭代出逐帧位置值做命令式动画。基本用法两秒上手import { spring } from remix-run/ui/animation // 使用预设 spring(bouncy) // 有回弹、带过冲 spring(snappy) // 快速、无过冲默认 spring(smooth) // 柔和、过阻尼 // 自定义弹簧 spring({ duration: 400, bounce: 0.3 })注意入口路径spring从remix-run/ui/animation子路径导出见 animation/index.ts同时导出的还有animateEntrance、animateExit、animateLayout、tween、easings及类型SpringIterator、SpringPreset、SpringOptions。不传任何参数时spring()默认取snappy预设源码resolveOptions在无参时直接返回presets.snappy见 spring.ts#L174-L191测试defaults to snappy when no args验证了这一行为。返回值一个可迭代、可展开、可字符串化的SpringIteratorspring()返回的不是普通对象而是一个“装饰过的”迭代器interface SpringIterator extends IterableIteratornumber { duration: number // CSS 时长毫秒例如 550 easing: string // CSS linear() 函数字符串 toString(): string // 550ms linear(...) }它有三种消费方式迭代逐帧 yield 0→1 的位置值用于 JavaScript 命令式动画展开{ ...spring() }展开为{ duration, easing }直接作为 WAAPI 的 timing 参数字符串化通过模板字符串或String()得到550ms linear(...)直接拼进 CSS transition。关键实现细节duration与easing是通过Object.defineProperties以enumerable: true定义在迭代器对象上的见 spring.ts#L119-L128这正是{ ...spring() }能展开出这两个字段的原因——测试can be spread for WAAPI专门断言了这一点。toString()则返回${duration}ms ${easing}。用于 CSS 过渡TransitionsSpringIterator的字符串化能力让它可以被无缝嵌入 CSS 字符串。模板字符串mix{[css({ transition: width ${spring(bouncy)} })]} // → width 550ms linear(...)多属性共享同一弹簧mix{[css({ transition: transform ${spring(bouncy)}, opacity ${spring(bouncy)} })]}使用 transition 辅助函数spring.transition(property, presetOrOptions?, overrides?)免去手写拼接mix{[css({ transition: spring.transition(width, bouncy) })]} // → width 550ms linear(...) mix{[css({ transition: spring.transition([left, top], snappy) })]} // → left 385ms linear(...), top 385ms linear(...)从源码看transition内部将属性名单个字符串会被包装成数组逐个与同一个弹簧字符串拼接再用,连接见 spring.ts#L133-L145。它同样接受自定义选项例如spring.transition(width, { duration: 500, bounce: 0.2 })。值得注意示例中spring(bouncy)字符串化为550msspring(snappy)为385ms——这里的时长是计算出的沉降时间settling time即弹簧从开始到完全静止的帧时长而不是你传入的“感知时长”参数默认 300ms。二者的换算关系见下文“底层实现”一节。与动画 Mixins 组合Remix UI 的动画 mixinsanimateEntrance、animateExit把非 timing 的样式属性拷入 WAAPI keyframes而duration、easing等被视为 timing 选项。因此把弹簧展开进 mixin 配置即可获得物理入场/退场mix{[ animateEntrance({ opacity: 0, transform: scale(0.9), ...spring(bouncy) }), animateExit({ opacity: 0, ...spring(snappy) }), ]}animateEntrance在元素插入时从给定 keyframe 动画到自然样式animateExit则让被移除的 keyed 元素在 DOM 中多停留一段时间以播完退场动画。弹簧与这两个 mixin 的组合是弹窗、Toast、列表项增删的常见做法参考 animation/README.md 中的用法示例。预设Presets预设BounceDuration特性smooth-0.3400ms过阻尼overdamped无过冲snappy0200ms临界阻尼快速bouncy0.3300ms欠阻尼明显回弹一处需要以源码为准的出入文档预设表将bouncy的 duration 记为 300ms但当前仓库源码 spring.ts#L52-L56 中的presets常量实际定义为bouncy: { duration: 400, bounce: 0.3 }测试spring.presets.bouncy断言也是{ duration: 400, bounce: 0.3 }。由于 duration 参数只影响弹簧的刚度感知时长实际沉降时长仍由物理计算得出具体数值请以你使用的版本源码为准。覆盖预设时长spring(bouncy, { duration: 300 }) // 更快的 bouncy spring(smooth, { duration: 800 }) // 更慢的 smooth第二个参数overrides的类型是OmitSpringOptions, bounce即只能覆盖 duration 与 velocity不能覆盖 bounce——回弹量是预设的“身份特征”。源码resolveOptions中正是这样处理bounce始终取预设值只有duration、velocity可以被覆盖见 spring.ts#L174-L191。自定义弹簧参数spring({ duration: 500, // 感知时长毫秒影响刚度 bounce: 0.3, // -1 到 1负过阻尼0临界正回弹 velocity: 0, // 初始速度单位/秒 })Bounce 值的物理语义bounce 0过阻尼overdamped沉降更慢、无过冲bounce 0临界阻尼critically damped无过冲前提下最快稳定bounce 0欠阻尼underdamped有回弹、会冲过目标。spring({ bounce: -0.5 }) // 非常平滑、缓慢 spring({ bounce: 0 }) // 干脆利落无回弹 spring({ bounce: 0.3 }) // 轻微回弹 spring({ bounce: 0.7 }) // 非常弹底层实现中bounce 会被钳制在[-1, 0.95]区间Math.max(-1, Math.min(0.95, bounce))再映射为阻尼比 ζbounce 0时ζ 1 - bounce线性地从临界阻尼滑向欠阻尼bounce 0时ζ 1 / (1 bounce)bounce 越接近 -1 阻尼越强见 spring.ts#L200-L215。初始速度Velocity延续手势动量velocity以“单位/秒”表示用于把用户的拖拽动量延续进弹簧// 正 朝目标方向运动过冲更明显 // 负 背离目标运动耗时更长 spring(bouncy, { velocity: 2 }) // 起步快 spring(bouncy, { velocity: -1 }) // 先向反方向走测试positive velocity causes faster initial movement验证了正速度会让早期帧的位置更大。从拖拽计算归一化速度// velocity 单位是 px/sdistance 单位是 px let normalizedVelocity velocityTowardTarget / distanceToTarget spring(bouncy, { velocity: normalizedVelocity })归一化的意义在于把速度与剩余距离放到同一量纲速度方向符号决定是“冲向目标”还是“背离目标”而速度/距离的比值决定了动量相对剩余行程的强弱。这一模式在仓库的 spring.demo.tsx 拖拽回弹演示中有完整落地——松手时分别对 X、Y 轴计算velocityX / distX与velocityY / distY钳制到[-20, 20]后生成各自的弹簧 transition。迭代做 JavaScript 动画弹簧迭代器逐帧约 60fps每帧约 16.67msyield 0→1 的位置值直到弹簧静止let s spring(bouncy) for (let t of s) { console.log(t) // 0, 0.015, 0.058, 0.121, ... 1 }源码中的生成器逻辑spring.ts#L108-L115为从t 0开始每次t frameMs并yield position(t)直到t超过沉降时间后补一个yield 1收尾。迭代结束后done为true。用进度插值任意值把 0→1 的进度当作插值因子即可驱动任何数值let from 100 let to 500 for (let t of spring(bouncy)) { let value from (to - from) * t // 100 → 500 updateSomething(value) await nextFrame() }Canvas 动画let s spring(bouncy) function draw() { let { value, done } s.next() ctx.clearRect(0, 0, canvas.width, canvas.height) ctx.beginPath() ctx.arc(value * 400, 100, 20, 0, Math.PI * 2) // x: 0 → 400 ctx.fill() if (!done) requestAnimationFrame(draw) } draw()多属性同时动画let fromX 0, toX 200 let fromY 0, toY 100 let fromScale 0.5, toScale 1 for (let t of spring(bouncy)) { let x fromX (toX - fromX) * t let y fromY (toY - fromY) * t let scale fromScale (toScale - fromScale) * t render({ x, y, scale }) await nextFrame() }颜色插值let fromRGB [255, 0, 0] // 红 let toRGB [0, 0, 255] // 蓝 for (let t of spring(smooth)) { let r Math.round(fromRGB[0] (toRGB[0] - fromRGB[0]) * t) let g Math.round(fromRGB[1] (toRGB[1] - fromRGB[1]) * t) let b Math.round(fromRGB[2] (toRGB[2] - fromRGB[2]) * t) element.style.backgroundColor rgb(${r}, ${g}, ${b}) await nextFrame() }访问原始值与预设默认值let { duration, easing } spring(bouncy) // duration: 550 (ms) // easing: linear(0.0000, 0.0156, ...) spring.presets // { // smooth: { duration: 400, bounce: -0.3 }, // snappy: { duration: 200, bounce: 0 }, // bouncy: { duration: 300, bounce: 0.3 } // }spring.presets暴露的是预设的参数配置duration/bounce而非计算后的沉降时长同时它也常被用来枚举预设名例如 demo 中Object.keys(spring.presets)生成预设切换按钮。Web Animations APIWAAPI由于duration与easing可枚举展开即得合法的 WAAPI timing 选项element.animate(keyframes, { ...spring(bouncy), })底层实现从 SwiftUI 公式到 CSSlinear()spring的物理内核基于 SwiftUI 的 spring 数学质量取 1源码头部注释给出了参数换算公式stiffness (2π ÷ duration)²其中 duration 为感知时长毫秒换算为秒后代入阻尼bounce ≥ 0时damping 1 - 4π × bounce ÷ durationbounce 0时damping 4π ÷ (duration 4π × bounce)。核心计算在computeSpringspring.ts#L200-L276中完成按阻尼比 ζ 分三种解析解给出位置函数ζ 1欠阻尼指数衰减包络 × 正弦振荡产生过冲回弹ζ 1过阻尼两个不同衰减率的指数项叠加无振荡缓慢收敛ζ 1临界阻尼最快的无振荡收敛。沉降时间的判定使用了两个静止阈值restSpeed 0.01速度阈值与restDelta 0.005位移阈值以 50ms 步长扫描上限maxSettlingTime 20_000ms测试断言duration 20000即源于此。扫描到速度与位移同时低于阈值的时间点即为settlingTimeduration取它的四舍五入值。CSS 缓动字符串由generateEasing生成先用自适应采样adaptiveSample容差0.002、最小分段8ms、最大细分深度 12在曲率大的区域加密采样点、近线性区域稀疏采样再把每个采样点格式化为linear(v1 p1%, v2 p2%, ..., 1)的形式数值保留 4 位小数见 spring.ts#L279-L333。这正是它能同时获得“物理真实”与“CSS 原生兼容”的原因。完整示例点击展开的卡片综合以上所有能力一个带弹簧过渡的可展开卡片function AnimatedCard(handle: Handle) { let isExpanded false return () ( div mix{[ css({ transition: spring.transition([width, height], bouncy), }), on(click, () { isExpanded !isExpanded handle.update() }), ]} style{{ width: isExpanded ? 300px : 100px, height: isExpanded ? 200px : 100px, }} Click me /div ) }进一步阅读动画模块总览animateEntrance、animateExit、animateLayout、tween的完整 API 与行为说明tween 文档基于 cubic-bezier 的补间动画适合无需物理感、由requestAnimationFrame时间戳驱动的场景文档明确建议“大多数 UI 动画优先用 mixins 或带spring的 CSS transitions”spring 源码 与 spring 测试测试覆盖了接口形状、预设默认值、物理不变量过冲/无过冲/沉降时长单调性以及transition辅助函数演示代码 spring.demo.tsx弹簧预设切换与拖拽释放速度回弹的完整可运行示例。【免费下载链接】remixThe fully-stacked web framework项目地址: https://gitcode.com/GitHub_Trending/re/remix创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考