跳转至

动效设计规范(local/anim-delight)

分支定位:自用分支,主题只有一个——动效。目标是把「丝滑、有回应、不喧哗」的观感铺到各个角落, 方向对齐 Apple 一类系统级动效的手感。本文件是该分支的写法约定,由 tools/motion-test.mjs (77 条断言)在提交前把关。

1. 为什么需要一份规范

动效最容易失控的地方不是"做不出来",而是做出来之后无法长期保持:

  • 时长、曲线各处硬编码,同一类交互在不同页面手感不一致;
  • 入场动画用 animation-fill-mode: both,动画结束帧把 transform 锁在终值, 压过 :hover / :active,按压回弹与悬浮抬起整体失效(本轮实测踩到过);
  • 为了让动画"看得见"而不断加长时长、加大位移,页面从顺滑变成拖沓;
  • 忘记 prefers-reduced-motion,对晕动症用户不友好。

因此约定写成工具可校验的形式,而不是靠记忆。

2. 五条硬纪律

编号 纪律 校验方式
M1 只动 transform / opacity 解析全部 @keyframes 体,出现 width/height/top/left/margin/padding/font-size 即失败
M2 时长与曲线走令牌,四档分级 --dur-1..4 存在、递增、均 ≤ 600ms;CSS 内无超过 700ms 的动画
M3 入场动画用 backwards,不用 both both 仅允许出现在退出动画(提示条淡出、描边勾)
M4 尊重系统「减弱动态效果」 CSS 降级块 + JS 侧 prefersReducedMotion() 双份
M5 重效果分级:模糊只在桌面,涟漪只在真机 backdrop-filter 被 hover:hover and pointer:fine 包住;涟漪判 is-phone
M6 不覆盖既有动画选择器 扫描 global.css 与 motion.css 中"定义了 animation 的选择器",交集必须落在允许清单内,且每条允许项写明理由

补充:无限循环动画仅限白名单三项——骨架流光 shimmer、状态点呼吸 m-breathe、 进度斜纹 m-stripes。其余动画一律只播一次。

3. 令牌

apps/desktop/src/styles/motion.css 顶部定义,命名固定。MD3 原语(§3.6)是唯一真源, 下面那组 --dur-* / --ease-* 是兼容层(组件继续用旧名,全应用一次性接上新曲线):

--md-sys-motion-easing-emphasized              (0.2, 0, 0, 1)      大容器 / 页面转场
--md-sys-motion-easing-emphasized-decelerate   (0.05, 0.7, 0.1, 1) 进场(起步快、尾巴长)
--md-sys-motion-easing-emphasized-accelerate   (0.3, 0, 0.8, 0.15) 退场(起步慢、收得快)
--md-sys-motion-easing-standard                (0.2, 0, 0, 1)      常规状态切换
--md-sys-motion-easing-standard-decelerate     (0, 0, 0, 1)
--md-sys-motion-easing-standard-accelerate     (0.3, 0, 1, 1)

--md-sys-motion-duration-short-2/4             100 / 200ms
--md-sys-motion-duration-medium-2/4            300 / 400ms
--md-sys-motion-duration-long-2                500ms

兼容层(组件里的写法不变):
--dur-1: 140ms                       微反馈(按压、悬浮)——保留原值,MD3 的 100/200 档在这太钝
--dur-2: short-4 (200ms)             常规(淡入、状态切换);原 220ms
--dur-3: medium-2 (300ms)            容器(弹层、页面);原 340ms
--dur-4: long-2 (500ms)              大场景(首屏揭示);原 520ms

--ease-out       = emphasized-decelerate   进场曲线
--ease-in-out    = standard                对称过渡
--ease-spring    轻弹簧(MD3 没有,本项目的品牌抖动,只给"被点/被弹出来"的元素)
--ease-ios       = emphasized              页面与大容器转场

--rise-1/2/3     位移基准 4 / 8 / 14px
--stagger        列表逐项间隔 26ms

进出场用不同曲线是硬纪律:进场一律 decelerate,退场一律 accelerate(.is-closing 相位、 sheet 下滑、toast 淡出)。若两者共用同一条曲线,退场会读作进场的倒放,方向感消失。

错峰出场(stagger):给容器加 stagger 类,其直接子元素按 26ms 递增上浮 (前 14 项零 JS 走 nth-child,更长的列表由 JS 给 --i)。只在一层用—— 父子同时进场会糊成一团。"待办"页 (tasks-learn) 就是这一层:筛选行 → 卡片流 → 统计/入口; 卡片流内部的卡片不加动画,那层是拖拽 transform 的地盘,动画会和手势抢属性。

4. 页面转场:只走 CSS 进场

App.tsx 的 .page-anim 以 key={page} 重新挂载,播一次淡入上浮;旧页直接卸载。 方向由 useNavDirection 比较"上一页与当前页"得出:进二级页为前进,回列表为后退, 分别对应不同曲线;二级页额外走横向滑入(data-level="sub")。

4.1 为什么不用 View Transitions 快照转场

2026-09-22 实测(用户报障):整页 tab 切换走快照交叉淡入时,旧页快照会在新页下层 以半透明残留——两张整页截图叠加,观感是"旧页面在下面闪一下",每次切换都闪。 快照转场适合"列表卡 → 详情页"这类有共享元素的场景,不适合整页替换。

因此该路径连同 withViewTransition 一并移除,并加回归护栏: tools/motion-test.mjs 断言源码中不再出现 startViewTransition、::view-transition 与 withViewTransition。后续若要做共享元素转场,按"列表卡 ↔ 详情页"单独设计, 不复用整页替换这条路。

5. 覆盖到的位置

位置 动效
按钮 / 图标钮 / 芯片 / 可点行 统一按压回弹,时长 140ms 弹簧曲线
桌面 hover 只有"点进去会跳转"的入口大卡上浮;数字卡片、列表行不位移,只改边框/底色
列表 / 统计卡 / 网格 逐项进场,间隔 26ms,前 14 项由 nth-child 提供延迟
侧栏当前项 左侧强调条「长出来」+ 图标轻微放大
抽屉导航 面板沿用 global.css 的 drawer-in/out(不覆盖),仅导航项逐项进场
页签(信息 / 生活 / 预约 / 收藏夹) 容器保持挂载,只切类名重放一次进场(页签状态不丢)
弹层(确认框 / 表单 / 插件 Sheet / 订阅 / 排课 / 导览 / 论坛) 遮罩淡入 + 面板弹簧
文件预览 面板同款进场 + PDF/pptx 逐页淡入
提示条 进入弹簧下落,退出先播 200ms 淡出再卸载(状态机 closing 相位)
统计数字 数值型从旧值滚到新值;–、¥12.34 一类字符串原样显示
主题切换 <html> 短暂挂 .theme-anim,颜色 320ms 过渡,420ms 后摘除
移动端顶栏 滚过 8px 浮起(阴影渐显)
折叠箭头 / 开关旋钮 旋转与缩放走弹簧曲线
触摸 涟漪,仅在 is-phone 密度层,全局单监听

6. 覆盖既有动画的两个真实事故(M6 的由来)

2026-09-22 用户实测报障,两起都是"动效层覆盖了既有动画"引起的:

抽屉消失:.drawer 的基态是 transform: translateX(-100%)(入场前的藏身态), 可见位置完全靠 global.css 的 drawer-in … forwards 钉住。动效层把该规则覆盖成 backwards 后,动画一结束基态立即生效——展开动画播完抽屉当场消失;关闭时 drawer-closing 又被同特异度的后位规则压过,于是出现"重现一次再播退场"。

提示条偏心:.toast-host 基态是 transform: translateX(-50%)(水平居中)。 新动画的位移里没有带上 -50%,入场期间提示条会先跳到偏右再弹回中间。 同时修掉一处选择器错配:退出动画原本写成 .toast.is-closing,而组件渲染的是 .toast-host.is-closing,退出相位从未生效。

结论:动效层只新增,不改既有动画;确需覆盖时进允许清单并写明理由 (当前仅 .content 与 .toast-host),由 M6 在提交前拦截。

7. 验证

node tools/motion-test.mjs      # 77 条断言:纪律 + 接线 + 无障碍 + 不越权覆盖
node tools/theme-plugin-sync-test.mjs   # 主题切换动效不得影响主题本身
pnpm --filter @onethu/desktop build     # CSS 语法与打包
pnpm exec tsc --noEmit                  # 类型

桌面端资源为编译期内嵌,只改前端时 cargo 不会自动重编,需要先 touch src/lib.rs src/main.rs 再执行 cargo xwin build --release --target x86_64-pc-windows-msvc --features tauri/custom-protocol, 否则部署出来的仍是旧界面。