动画层级与悬停配方
经过 17 个原型应用验证的两个核心理念:
- 持续时间是一个尺度,而不是每个组件各自拥有一个数字。 视觉会将统一的节奏识别为同一产品。较小的元素运动更快。
- 悬停会温和地移动几何。 一种渐入行内的高亮、一个“抵达”的箭头、一个向目标方向“倾斜”的V形符号、一个“滚动”到下一状态的标签。纯颜色变化的悬停读起来就像开关切换。
文件
references/recipes.css:Tailwind v4 的@theme令牌和@utility配方。references/timing.ts:适用于 Motion 和 WAAPI 的相同层级。references/swap.tsx:IconSwap和LabelSwap(React 组件)。
层级
| 层级 | 毫秒 | 适用场景 |
|---|---|---|
tap | 100 | 图标交换、按下动作 |
pop | 150 | 菜单、弹出框或工具提示的打开 |
hover | 200 | 行或控件响应指针 |
card | 300 | 卡片抬起、列表稳定、页面切换 |
layout | 500 | 面板折叠、页面重排 |
- 进入动画使用缓出曲线(
cubic-bezier(0.22,1,0.36,1))。退出动画使用缓入曲线,并缩短一个层级。 - 当两个元素交换位置时,离开的元素立即退出,进入的元素在
delay-handoff(50毫秒)延迟后开始。 - 数值补间动画(计数器、进度条、图表线条)可以持续更长时间:视觉正在跟随数据。
- 保留有意识的弹簧效果。
迁移:将所有临时定义的持续时间映射到最近的层级。
| 临时值 | 层级 |
|---|---|
| 0.12 | tap |
| 0.15–0.18 | pop |
| 0.2–0.24 | hover |
| 0.28–0.35 | card |
| 0.4–0.5 | layout |
几乎在所有地方,屏幕上的时间变化预计不超过 20 毫秒。
配方(每个元素一个)
hover-grow(导航行、链接行):- 高亮是一个
::before伪元素,起始时内嵌(3px 宽,6px 高)且透明,然后随着淡入扩展到整行。 - 它也会在
:focus-visible、data-state=open和aria-current=page状态下显示。 - 方形或粗犷的设计(类似 Carbon)应保持扁平高亮。
- 高亮是一个
trail(链接行、“浏览计划”):- 一个尾部字形,在父元素
.group悬停或聚焦之前,以一半比例且透明状态呈现,从其前缘开始生长。 trail-out(从左下角生长)加上arrow-up-right标记离开当前应用的链接。
- 一个尾部字形,在父元素
nudge/nudge-back(V形符号、“查看全部”、返回链接):向目标方向倾斜 2 像素。lift(可点击的卡片):- 表面亮度提高一层,阴影深度增加一层,作用于
card层级。 - 永远不要平移:在光标下跳动的卡片会移动正在被瞄准的目标。
- 表面亮度提高一层,阴影深度增加一层,作用于
scroll-fade(滚动窗格):- 基于遮罩的边缘淡出,仅应用于仍可滚动的边缘。它由容器自身的滚动时间线驱动,无需背景颜色。
- 不支持的浏览器则不显示淡出效果。
IconSwap(菜单变成关闭、复制变成对勾、太阳变成月亮):- 离开的图标缩小、倾斜 -22° 并以缓入曲线淡出。
- 进入的图标在手柄延迟后跟随进入。
- 两者始终保持挂载状态,以便中途能够干净地反转。
LabelSwap(“复制链接”变成“已复制”、“折叠”变成“展开”):- 两个标签位于同一个网格单元格中,因此不会有宽度跳跃。
- 隐藏的标签通过
transition-discrete变为invisible,因此辅助技术只能听到当前显示的标签。
陷阱
- 在自身有动画的元素上使用
scroll-fade(Radix 菜单、弹出框或选择内容)会替换该元素的退出动画。Radix 等待一个永不结束的退出,导致菜单保持打开。应将淡出放在内部的滚动子元素上。 scroll-fade也会遮罩边框和阴影。 不要将其放在带边框的卡片上;应放在内部滚动器上。- Tailwind v4 中自定义
duration-*工具类也必须设置--tw-duration,否则同一元素上的transition-[…]工具类会根据排序顺序胜出。 - 带有行高的命名类型步骤(
--text-meta--line-height)在替换继承了父元素行高的任意大小时,会导致每个转换后的行偏移一个像素。为中间步骤指定自身无行高。 - 将悬停效果限制在
@media (hover: hover)之后。 在:focus-visible上显示相同状态,并尊重prefers-reduced-motion(Motion 使用:<MotionConfig reducedMotion="user">)。