做动画/motion design HTML时读这个。原理、用法、典型模式。
我们的动画系统(assets/animations.jsx)提供一个时间轴驱动的引擎:
<Stage>:整个动画的容器,自动提供auto-scale(fit viewport)+ scrubber + play/pause/loop控制<Sprite start end>:时间片段。一个Sprite只在start到end这段时间内显示。内部可以通过useSprite() hook读取自己的本地进度t (0→1)useTime():读当前全局时间(秒)Easing.easeInOut / Easing.easeOut / ...:缓动函数interpolate(t, from, to, easing?):根据t插值这套模式借鉴Remotion/After Effects思路,但轻量、零依赖。
<script type="text/babel" src="animations.jsx"></script>
<script type="text/babel">
const { Stage, Sprite, useTime, useSprite, Easing, interpolate } = window.Animations;
function Title() {
const { t } = useSprite(); // 本地进度 0→1
const opacity = interpolate(t, [0, 1], [0, 1], Easing.easeOut);
const y = interpolate(t, [0, 1], [40, 0], Easing.easeOut);
return (
<h1 style={{
opacity,
transform: `translateY(${y}px)`,
fontSize: 120,
fontWeight: 900,
}}>
Hello.
</h1>
);
}
function Scene() {
return (
<Stage duration={10}> {/* 10秒动画 */}
<Sprite start={0} end={3}>
<Title />
</Sprite>
<Sprite start={2} end={5}>
<SubTitle />
</Sprite>
{/* ... */}
</Stage>
);
}
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<Scene />);
</script>
function FadeIn({ children }) {
const { t } = useSprite();
const opacity = interpolate(t, [0, 0.3], [0, 1], Easing.easeOut);
return <div style={{ opacity }}>{children}</div>;
}
注意范围:[0, 0.3]意思是在sprite的前30%时间完成渐入,后面保持opacity=1。
function SlideIn({ children, from = 'left' }) {
const { t } = useSprite();
const progress = interpolate(t, [0, 0.4], [0, 1], Easing.easeOut);
const offset = (1 - progress) * 100;
const directions = {
left: `translateX(-${offset}px)`,
right: `translateX(${offset}px)`,
top: `translateY(-${offset}px)`,
bottom: `translateY(${offset}px)`,
};
return (
<div style={{
transform: directions[from],
opacity: progress,
}}>
{children}
</div>
);
}
function Typewriter({ text }) {
const { t } = useSprite();
const charCount = Math.floor(text.length * Math.min(t * 2, 1));
return <span>{text.slice(0, charCount)}</span>;
}
function CountUp({ from = 0, to = 100, duration = 0.6 }) {
const { t } = useSprite();
const progress = interpolate(t, [0, duration], [0, 1], Easing.easeOut);
const value = Math.floor(from + (to - from) * progress);
return <span>{value.toLocaleString()}</span>;
}
function Scene() {
return (
<Stage duration={20}>
{/* Phase 1: 展示问题 */}
<Sprite start={0} end={4}>
<Problem />
</Sprite>
{/* Phase 2: 展示思路 */}
<Sprite start={4} end={10}>
<Approach />
</Sprite>
{/* Phase 3: 展示结果 */}
<Sprite start={10} end={16}>
<Result />
</Sprite>
{/* 全程显示的字幕 */}
<Sprite start={0} end={20}>
<Caption />
</Sprite>
</Stage>
);
}
预设的easing curves:
| Easing | 特性 | 用在 |
|---|---|---|
linear |
匀速 | 滚动字幕、持续动画 |
easeIn |
慢→快 | 退场消失 |
easeOut |
快→慢 | 入场出现 |
easeInOut |
慢→快→慢 | 位置变化 |
expoOut ⭐ |
指数缓出 | Anthropic 级主 easing(物理重量感) |
overshoot ⭐ |
弹性回弹 | Toggle / 按钮弹出 / 强调交互 |
spring |
弹簧 | 交互反馈、几何体归位 |
anticipation |
先反向再正向 | 强调动作 |
默认主 easing 用 expoOut(不是 easeOut)—— 见 animation-best-practices.md §2。
入场用 expoOut、出场用 easeIn、toggle 用 overshoot——Anthropic 级动画的基础规律。
人类注意力有限。10秒讲一件事,讲完换下一件。
错误:先想要做fancy动画,再塞内容进去 正确:先想清楚要传达什么信息,再用动画手段serve这个信息
动画是signal,不是装饰。一个fade-in强调的是"这里很重要,请看"——如果什么都fade-in,signal就失效。
0:00 - 0:03 问题出现(fade in)
0:03 - 0:06 问题放大/展开(zoom+pan)
0:06 - 0:09 解法出现(slide in from right)
0:09 - 0:12 解法展开说明(typewriter)
0:12 - 0:15 结果演示(counter up + chart reveal)
0:15 - 0:18 总结一句话(static,读3秒)
0:18 - 0:20 CTA或fade out
写完时间轴再写组件。
动画要用的图片/图标/字体先准备好。不要画到一半去找素材——打断节奏。
动画卡顿
→ 主要是layout thrashing。用transform和opacity,不要动top/left/width/height/margin。浏览器GPU加速transform。
动画太快,看不清楚 → 人读一个汉字需要100-150ms,一个词300-500ms。如果你用文字讲故事,单句至少留3秒。
动画太慢,观众无聊 → 有趣的视觉变化要密集。静态画面超过5秒就会闷。
多个动画互相影响
→ 用CSS的will-change: transform提前告诉浏览器这个元素会动,减少reflow。
录制成视频
→ 用 skill 自带工具链(一条命令出三种格式):见 video-export.md
scripts/render-video.js — HTML → 25fps MP4(Playwright + ffmpeg)scripts/convert-formats.sh — 25fps MP4 → 60fps MP4 + 优化 GIFanimation-pitfalls.md 第 5 条这个skill做的是HTML动画(在浏览器里跑的)。如果最终产出要作为视频素材:
如果你真的需要物理动画(spring、decay、keyframes with precise timing),我们的engine搞不定,可以fallback到Popmotion:
<script src="https://unpkg.com/popmotion@11.0.5/dist/popmotion.min.js"></script>
但先试试我们的engine。90%的情况够用。