基础 · FOUNDATION
动效 Motion
产品内动效 150–250ms,只传递状态。标准缓动 cubic-bezier(.2,.7,.2,1)。戏剧化只留给首次进站的开屏,之后产品永远安静。
重播
zzmSpring1.1s
弹性落定
zzmIn.35s
进场上浮
zzmSlide.28s
侧栏滑入
zzmGrow.55s
展开生长
zzmPulse2.6s ∞
当前节点呼吸
import { useState } from 'react'
import { Btn } from './components/v4/Button.jsx'
const ACCENT = 'var(--zzm-accent, #A6402F)'
const EASE = 'cubic-bezier(.2,.7,.2,1)'
const MONO = "ui-monospace,'SF Mono',Menlo,monospace"
const ROWS = [
['zzmSpring', '1.1s', '弹性落定', { width: 20, height: 20, borderRadius: '50%', background: ACCENT, animation: `zzmSpring 1.1s ${EASE} both` }],
['zzmIn', '.35s', '进场上浮', { width: 96, height: 20, borderRadius: 4, background: '#1D1D1B', animation: 'zzmIn .35s ease both' }],
['zzmSlide', '.28s', '侧栏滑入', { width: 96, height: 20, borderRadius: 4, background: '#8B8B85', animation: 'zzmSlide .28s ease both' }],
['zzmGrow', '.55s', '展开生长', { width: 160, height: 20, borderRadius: 4, background: '#D6D6D1', overflow: 'hidden', animation: `zzmGrow .55s ${EASE} both` }],
['zzmPulse', '2.6s ∞', '当前节点呼吸', { width: 20, height: 20, borderRadius: '50%', border: `1.5px solid ${ACCENT}`, animation: 'zzmPulse 2.6s ease-in-out infinite' }],
]
export default function MotionReplay() {
const [n, setN] = useState(0)
return (
<div style={{ display: 'grid', gap: 12, width: '100%' }}>
{ROWS.map(([name, dur, use, style]) => (
<div key={name} style={{ display: 'grid', gridTemplateColumns: '96px 56px minmax(0,1fr) 96px', gap: 12, alignItems: 'center', minHeight: 28 }}>
<span style={{ fontFamily: MONO, fontSize: 11, color: '#161616' }}>{name}</span>
<span style={{ fontFamily: MONO, fontSize: 11, color: '#A2A29C' }}>{dur}</span>
<div style={{ minWidth: 0 }}><div key={n} style={style} /></div>
<span style={{ fontSize: 12, color: '#8B8B85' }}>{use}</span>
</div>
))}
<div><Btn size="sm" onClick={() => setN(n + 1)}>重播</Btn></div>
</div>
)
}<div style="display: grid; gap: 12px; width: 100%">
<div
style="
display: grid;
grid-template-columns: 96px 56px minmax(0, 1fr) 96px;
gap: 12px;
align-items: center;
min-height: 28px;
"
>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#161616"
>
zzmSpring
</span>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#A2A29C"
>
1.1s
</span>
<div style="min-width: 0">
<div
style="
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--zzm-accent, #a6402f);
animation: zzmSpring 1.1s cubic-bezier(0.2, 0.7, 0.2, 1) both;
"
></div>
</div>
<span style="font-size: 12px; color: #8b8b85">弹性落定</span>
</div>
<div
style="
display: grid;
grid-template-columns: 96px 56px minmax(0, 1fr) 96px;
gap: 12px;
align-items: center;
min-height: 28px;
"
>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#161616"
>
zzmIn
</span>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#A2A29C"
>
.35s
</span>
<div style="min-width: 0">
<div
style="
width: 96px;
height: 20px;
border-radius: 4px;
background: #1d1d1b;
animation: zzmIn 0.35s ease both;
"
></div>
</div>
<span style="font-size: 12px; color: #8b8b85">进场上浮</span>
</div>
<div
style="
display: grid;
grid-template-columns: 96px 56px minmax(0, 1fr) 96px;
gap: 12px;
align-items: center;
min-height: 28px;
"
>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#161616"
>
zzmSlide
</span>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#A2A29C"
>
.28s
</span>
<div style="min-width: 0">
<div
style="
width: 96px;
height: 20px;
border-radius: 4px;
background: #8b8b85;
animation: zzmSlide 0.28s ease both;
"
></div>
</div>
<span style="font-size: 12px; color: #8b8b85">侧栏滑入</span>
</div>
<div
style="
display: grid;
grid-template-columns: 96px 56px minmax(0, 1fr) 96px;
gap: 12px;
align-items: center;
min-height: 28px;
"
>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#161616"
>
zzmGrow
</span>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#A2A29C"
>
.55s
</span>
<div style="min-width: 0">
<div
style="
width: 160px;
height: 20px;
border-radius: 4px;
background: #d6d6d1;
overflow: hidden;
animation: zzmGrow 0.55s cubic-bezier(0.2, 0.7, 0.2, 1) both;
"
></div>
</div>
<span style="font-size: 12px; color: #8b8b85">展开生长</span>
</div>
<div
style="
display: grid;
grid-template-columns: 96px 56px minmax(0, 1fr) 96px;
gap: 12px;
align-items: center;
min-height: 28px;
"
>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#161616"
>
zzmPulse
</span>
<span
style="font-family:ui-monospace,'SF Mono',Menlo,monospace;font-size:11px;color:#A2A29C"
>
2.6s ∞
</span>
<div style="min-width: 0">
<div
style="
width: 20px;
height: 20px;
border-radius: 50%;
border: 1.5px solid var(--zzm-accent, #a6402f);
animation: zzmPulse 2.6s ease-in-out infinite;
"
></div>
</div>
<span style="font-size: 12px; color: #8b8b85">当前节点呼吸</span>
</div>
<div><button type="button" class="zzm-btn zzm-btn--sm">重播</button></div>
</div>/* tokens.css · 用到的令牌 */
:root {
--zzm-accent: #A6402F;
}
/* controls.css */
.zzm-v4 .zzm-btn {
-webkit-appearance: none;
appearance: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
height: 36px;
padding: 0 16px;
font-family: inherit;
font-size: 13px;
font-weight: 600;
letter-spacing: .2px;
white-space: nowrap;
cursor: pointer;
user-select: none;
border-radius: 8px; /* 圆角矩形——按钮禁胶囊 */
border: 1px solid rgba(0, 0, 0, .14);
color: #3F3F3B;
background: linear-gradient(180deg, #FFFFFF 0%, #F2F2EF 100%);
box-shadow:
0 0 0 3px rgba(255, 255, 255, .55), /* ④ 反光光晕环 */
0 1px 2px rgba(0, 0, 0, .09), /* ③ 近影 */
0 2px 6px rgba(0, 0, 0, .06), /* ③ 远影 */
inset 0 1px 0 rgba(255, 255, 255, .92), /* ② 顶部反光 */
inset 0 -1px 0 rgba(0, 0, 0, .05); /* ① 底缘收沉 */
transition: box-shadow .16s ease, transform .16s ease, background .16s ease,
border-color .16s ease, filter .16s ease, opacity .16s ease;
}
.zzm-v4 .zzm-btn:hover {
transform: translateY(-1px);
border-color: rgba(0, 0, 0, .18);
box-shadow:
0 0 0 4px rgba(255, 255, 255, .8),
0 2px 4px rgba(0, 0, 0, .10),
0 4px 12px rgba(0, 0, 0, .09),
inset 0 1px 0 rgba(255, 255, 255, .95),
inset 0 -1px 0 rgba(0, 0, 0, .05);
}
.zzm-v4 .zzm-btn:active {
transform: translateY(0);
background: linear-gradient(180deg, #ECECE9 0%, #F4F4F1 100%); /* ⑤ 凹面翻转 */
box-shadow:
0 0 0 3px rgba(255, 255, 255, .45),
0 1px 0 rgba(255, 255, 255, .65),
inset 0 2px 4px rgba(0, 0, 0, .13),
inset 0 1px 2px rgba(0, 0, 0, .09);
}
.zzm-v4 .zzm-btn:focus-visible {
outline: none;
box-shadow:
0 0 0 3px rgba(166, 64, 47, .20), /* 键盘焦点=朱色光晕 */
0 1px 2px rgba(0, 0, 0, .09),
0 2px 6px rgba(0, 0, 0, .06),
inset 0 1px 0 rgba(255, 255, 255, .92);
}
.zzm-v4 .zzm-btn:disabled {
cursor: not-allowed;
transform: none;
}
.zzm-v4 .zzm-btn:disabled:not(.zzm-btn--busy) {
color: #A2A29C;
background: #F2F2EF;
border-color: rgba(0, 0, 0, .08);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, .7), 0 1px 2px rgba(0, 0, 0, .04);
}
.zzm-v4 .zzm-btn--sm { height: 28px; padding: 0 12px; font-size: 12px; gap: 4px; }
.zzm-v4 .zzm-btn--icon.zzm-btn--sm { width: 28px; }缓动与时长
| 用途 | 值 |
|---|---|
| 标准缓动 | cubic-bezier(.2,.7,.2,1)——快起慢落 |
| 悬停 / 按压过渡 | .16s ease(按钮、卡片、链接) |
| 状态切换 | 150–250ms |
| 进场 | zzmIn .35s、zzmUp .25s |
| 展开 | zzmGrow .55s |
| 弹性强调 | zzmSpring 1.1s |
关键帧清单
| 名称 | 效果 | 用在哪 |
|---|---|---|
zzmPulse | 透明度 .15 ↔ .55 呼吸 | 学习地图当前节点光晕 |
zzmIn / zzmIn2 | 上移 12px 淡入 | 屏幕进场 |
zzmUp / zzmUp2 | 上移 10px 淡入 | 卡片、提示进场 |
zzmSlide | 左移 14px 淡入 | 侧栏滑入 |
zzmGrow | max-height 0 → 960 展开 | 折叠面板 |
zzmSpring | 横移过冲再回弹 | 弹性演示 |
zzmFade | 纯淡入 | 遮罩 |
zzmPayIn | 上移 20px + 缩放 .985 淡入 | 付款页 |
zzmShimmer | 一道微光横扫 | 骨架屏 |
zzmBtnSpin | 旋转一周 | busy 键旋转环 |
zzmPillShine | 快扫一次后长歇 | 档位胶囊反光、busy 键反光 |
全部带 zzm 前缀,全局注册无副作用;组件里用内联 animation: 引用。@keyframes zzmDialogIn 是提炼层为确认窗新增的一条。
减少动效
prefers-reduced-motion: reduce 时,所有动画与过渡时长压到 0.01ms、只播一次。这段规则在 zzm.css 的基线段里,逐字取自 edu。
css
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}铁律
✓ 要动效只传递状态:出现、展开、按下、完成
✗ 不要装饰性循环动画(呼吸光晕只给「当前位置」)
✓ 要动 transform 和 opacity
✗ 不要动 width / height / padding 引起重排
✓ 要尊重减少动效设置
✗ 不要开屏式大动画出现在第二次访问