关于 animate-ui
为 Prototyper UI 组件提供动画规范的设计技能,面向实现和评审界面的开发者。它汇总可复制的 Tailwind 类、时长与缓动规则,并涵盖无障碍和性能约束。
主要功能
- 01提供常见组件动画模式
- 02定义缓动曲线与时长等级
- 03规范覆盖层定位与缩放
- 04限制可动画属性与过渡写法
- 05提供减少动态效果的无障碍规则
适用场景
- 01为弹窗和菜单添加进出场动画
- 02统一按钮及控件的按压反馈
- 03检查动画性能与可中断性
- 04为动态效果补充减少动态设置
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ prototyperai/ prototyper-ui --skill animate-ui安装后在 Claude Code 中输入/animate-ui确认已加载。
详细介绍
Prototyper UI — 动画约定
Prototyper UI 组件的所有动画决策。先介绍模式,再列出速查表,接着说明规则,最后介绍各组件的细节。
1. 复制粘贴模式
以下五种模式涵盖了约 95% 的动画工作。请原样复制类字符串。
模式 A:定位浮层
适用于:Popover、Menu、Tooltip、Select、Combobox、Context Menu、HoverCard、Menubar。
className={cn(
// Enter
"data-open:animate-in data-open:duration-150",
"data-open:[animation-timing-function:var(--ease-out-fluid)]",
"data-open:fade-in-0 data-open:zoom-in-95",
// Exit
"data-closed:animate-out data-closed:duration-100",
"data-closed:[animation-timing-function:var(--ease-in-quart)]",
"data-closed:fade-out-0 data-closed:zoom-out-95",
// Direction-aware slide (2 = 0.5rem)
"data-[side=bottom]:slide-in-from-top-2",
"data-[side=top]:slide-in-from-bottom-2",
"data-[side=left]:slide-in-from-right-2",
"data-[side=right]:slide-in-from-left-2",
// Origin from trigger position
"origin-(--transform-origin)",
// Performance
"data-entering:will-change-[opacity,transform]",
"data-exiting:will-change-[opacity,transform]",
// Accessibility
"motion-reduce:animate-none motion-reduce:transition-none",
)}
模式 B:对话框(居中)
// Backdrop
className={cn(
"data-open:animate-in data-closed:animate-out",
"data-closed:fade-out-0 data-open:fade-in-0",
"duration-200",
"bg-black/10 supports-backdrop-filter:backdrop-blur-xs",
"motion-reduce:animate-none motion-reduce:transition-none",
)}
// Content — note the slight overshoot scale
className={cn(
"data-open:animate-in data-closed:animate-out",
"data-closed:fade-out-0 data-open:fade-in-0",
"data-closed:zoom-out-[0.98] data-open:zoom-in-[1.02]",
"duration-200 ease-out-fluid",
"data-entering:will-change-[opacity,transform]",
"data-exiting:will-change-[opacity,transform]",
"motion-reduce:animate-none motion-reduce:transition-none",
)}
Dialog 使用 zoom-in-[1.02](轻微过冲)和 zoom-out-[0.98],比浮层的 zoom-in-95/zoom-out-95 更微妙。
模式 C:侧边面板(按方向)
const sheetVariants = cva(
// Base: transition + split enter/exit duration
"transition ease-out-fluid " +
"data-open:animate-in data-open:duration-300 " +
"data-closed:animate-out data-closed:duration-200 " +
"data-entering:will-change-[opacity,transform] " +
"data-exiting:will-change-[opacity,transform] " +
"motion-reduce:animate-none motion-reduce:transition-none",
{
variants: {
side: {
top: "data-open:slide-in-from-top data-closed:slide-out-to-top",
bottom:
"data-open:slide-in-from-bottom data-closed:slide-out-to-bottom",
left: "data-open:slide-in-from-left data-closed:slide-out-to-left",
right: "data-open:slide-in-from-right data-closed:slide-out-to-right",
},
},
},
);
Sheet 会滑动 100%(从边缘到边缘),而不是像定位浮层那样使用 slide-*-2。
模式 D:按压反馈
"motion-safe:active:scale-[0.97]"; // Standard interactive elements
"motion-safe:active:scale-[0.98]"; // Menu items, tab triggers
"motion-safe:active:scale-[0.95]"; // Small controls
"motion-safe:active:scale-100"; // Explicitly disabled (ghost/link buttons)
始终添加 motion-safe: 前缀,绝不单独使用 active:scale-*。Checkbox 使用 motion-safe:group-active:scale-[0.95](group 变体)。
| 缩放值 | 组件 |
|---|---|
scale-[0.97] | Button(默认)、Switch(轨道)、Toggle、Toolbar(Button、Link)、Select(触发器) |
scale-[0.98] | Menu 项(Item、SubTrigger、CheckboxItem、RadioItem)、Tabs(触发器) |
scale-[0.95] | Checkbox(控件)、RadioGroup(项)、NumberField(Increment、Decrement) |
scale-100 | Button 的 ghost 变体、Button 的 link 变体 |
模式 E:状态过渡
"transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth";
"motion-reduce:transition-none";
适用于:Button、Checkbox、Combobox(输入框/标签/移除标签)、Field(FieldGroup)、Input、InputGroup、Menu 项、NumberField、RadioGroup、Select(触发器)、Tabs(触发器)、TextField、Textarea、Toggle、Toolbar(button/link/input)、NavigationMenu(trigger/link)、Accordion(trigger)。
2. 缓动 Token
定义于 apps/docs/registry/prototyper-tokens.css:
| Token | 值 | 使用场景 |
|---|---|---|
ease-smooth | cubic-bezier(0.4, 0, 0.2, 1) | 状态过渡、屏幕上的变化 |
ease-out-fluid | cubic-bezier(0.32, 0.72, 0, 1) | 进入的元素:浮层、侧边面板、开关/滑块滑块 |
ease-in-quart | cubic-bezier(0.895, 0.03, 0.685, 0.22) | 退出的元素:浮层关闭 |
ease-out-quad | cubic-bezier(0.25, 0.46, 0.45, 0.94) | 可用,目前未使用 |
ease-out-quart | cubic-bezier(0.165, 0.84, 0.44, 1) | 可用,目前未使用 |
ease-in-quad | cubic-bezier(0.55, 0.085, 0.68, 0.53) | 可用,目前未使用 |
ease-in-out-quad | cubic-bezier(0.455, 0.03, 0.515, 0.955) | 可用,目前未使用 |
决策树
Is the element entering or exiting?
├── Entering the screen → ease-out-fluid
├── Exiting the screen → ease-in-quart
└── No (staying on screen)
├── Moving or morphing? → ease-smooth
├── Color/background change? → ease-smooth
├── Constant motion (spinner, progress)? → linear
└── Default → ease-smooth
在 Tailwind 中
- 状态过渡:
ease-smooth(直接使用工具类) - 浮层进入:
data-open:[animation-timing-function:var(--ease-out-fluid)]或ease-out-fluid - 浮层退出:
data-closed:[animation-timing-function:var(--ease-in-quart)] - 居中 Dialog:
ease-out-fluid(进入和退出均使用) - Navigation menu:
ease-[cubic-bezier(0.22,1,0.36,1)](导航专用,不是 Token)
为什么使用 ease-out
UI 默认使用 ease-out,因为它开始时速度快、结束时减速,给人以立即响应的感觉。UI 动画绝不使用 ease-in——它会在结束时加速,让人感觉迟缓、反应迟钝。300ms ease-out 给人的感觉明显比 300ms ease-in 更快。内置 CSS 缓动曲线无法满足精致 UI 的需求,因此始终使用项目自定义的 Token。
3. 时长等级
| 等级 | 时长 | 用途 |
|---|---|---|
| Instant | 100ms | Alert Dialog、Context Menu、HoverCard、Menubar、Popover/Tooltip 退出动画 |
| Fast | 150ms | 状态转换(所有交互元素)、覆盖层进入动画 |
| Standard | 200ms | Dialog(居中)、sheet 退出动画、checkbox 控件/SVG、accordion、button 伪元素 |
| Medium | 250ms | Switch 轨道、slider 滑块 |
| Smooth | 300ms | Sheet 进入动画、switch 滑块、meter/progress 条 |
| Long | 350ms | Navigation menu 内容(大型滑动面板) |
规则
- UI 动画时长应低于 300ms(大型面板的导航菜单例外,为 350ms)
- 退出动画应比进入动画快 20-33%(150→100、300→200)
- 较大的元素动画较慢(dialog 200ms > tooltip 150ms > alert 100ms)
- 时长应与移动距离相匹配——移动越远,时长越长
- 按住确认操作使用约 2s 的线性动画;松开后的动画应较快(约 200ms ease-out)
- 频繁出现的动画会令人厌烦——应彻底移除,而不是缩短时长
4. 缩放值
| 场景 | 值 | 类 |
|---|---|---|
| 覆盖层进入/退出 | 0.95 | zoom-in-95 / zoom-out-95 |
| Dialog 进入 | 1.02(过冲) | zoom-in-[1.02] |
| Dialog 退出 | 0.98 | zoom-out-[0.98] |
| Nav menu 弹出层 | 0.90 | scale-90(起始/结束样式) |
| Button/toggle 按压 | 0.97 | scale-[0.97] |
| Menu item 按压 | 0.98 | scale-[0.98] |
| 小型控件按压 | 0.95 | scale-[0.95] |
| Slider 滑块拖动 | 0.85 | [&::after]:scale-[0.85] |
| 禁用按压的覆盖值 | 1.0 | scale-100 |
切勿从 scale(0) 开始动画——这会让人感觉突兀、不自然。最小起始缩放值为 0.85(slider 拖动)或 0.90(nav menu)。进入/退出动画始终应将缩放与透明度结合使用。
5. 与原点相关的覆盖层
所有定位的覆盖层都必须使用:
origin-(--transform-origin)
这会映射到 transform-origin: var(--transform-origin),Base UI 会根据弹出层相对于触发器的位置自动设置该值。若不使用它,覆盖层会从中心缩放,而不是从触发器缩放——这会让人感觉脱节。
适用对象: Popover、Menu、Tooltip、Select、Combobox、Context Menu、HoverCard/PreviewCard、Navigation Menu 弹出层。
6. 性能限制
仅对以下属性使用动画
transform(缩放、平移、旋转)——仅合成opacity——仅合成clip-path——硬件加速,不会造成布局偏移
切勿这样做
- 使用
transition-all——它会为布局属性添加动画,导致卡顿 - 直接对
width、height、padding、margin添加动画(例外:accordion 使用关键帧高度动画,这是可接受的) - 在运行时通过 CSS 自定义变量添加动画——设置 CSS 变量会触发样式重新计算,并波及所有子元素。应直接应用变换。
- 永久使用
will-change——仅在动画期间应用:data-entering:will-change-[opacity,transform] data-exiting:will-change-[opacity,transform]
明确列出过渡属性
切勿单独使用 transition。始终准确列出属性:
transition-[color,background-color,border-color,box-shadow,opacity] → state transitions
transition-[color,background-color,border-color,box-shadow,transform] → checkbox (includes scale)
transition-[background-color,transform] → slider thumb
transition-[margin-inline-start,background-color] → switch thumb
transition-[width,background-color] → meter/progress indicator
可中断性
对于交互式动画,CSS transitions 优于关键帧,因为 transitions 允许在序列中途重新设定目标——动画仍在运行时,你可以更改结束位置,动画会平滑地转向新目标。关键帧会锁定到固定终点,无法干净地中断。
Prototyper UI 中的所有状态转换都使用 CSS transition-*(完全可中断)。覆盖层的进入/退出使用关键帧(animate-in/animate-out)——这是可以接受的,因为它们持续时间很短(100-200ms),且由打开/关闭状态触发,而非由持续的用户交互触发。
7. 决策门
添加任何动画前,请回答三个问题:
- 它有明确的用途吗? 必须用于说明状态变化、提供响应反馈、构建空间逻辑,或带来愉悦感。
- 用户会多频繁地看到它? 不常见 = 合适的候选项。频繁出现或由键盘触发 = 完全跳过动画。
- 它会提升感知性能吗? 如果会造成延迟或阻塞交互,请重新考虑。
硬性规则:
- 绝不为键盘触发的操作添加动画——这会让人觉得缓慢、迟滞且脱节
- 绝不为用户在每个会话中使用数百次的操作添加动画(例如列表项悬停)
- Ghost 按钮和链接按钮使用
motion-safe:active:scale-100(明确不使用按压缩放) - 标签页内容面板不添加进入/退出动画(即时切换)
8. 无障碍检查清单
每个动画元素都必须包含所有适用项:
-
motion-reduce:transition-none— 用于任何带有transition-*类的元素 -
motion-reduce:animate-none— 用于任何带有animate-in/animate-out/animate-*类的元素 -
motion-safe:前缀 — 用于所有active:scale-*按压反馈(绝不裸用) -
hover-only:hover:— 用于不应在触屏设备上触发的悬停效果(防止悬停状态粘滞) -
motion-reduce:[&::after]:scale-100— 用于伪元素缩放效果(Slider) -
motion-reduce:after:transition-none— 用于伪元素过渡效果(Tabs)
自定义变体(定义于 prototyper-tokens.css):
@custom-variant motion-reduce (@media (prefers-reduced-motion: reduce));
@custom-variant motion-safe (@media (prefers-reduced-motion: no-preference));
@custom-variant hover-only (@media (hover: hover));
动画可能引发恶心感或分散注意力——务必提供减少动态效果的替代方案。优先使用淡入淡出而非弹跳,使用即时切换而非滑动。
9. 各组件覆盖层类
每种覆盖层组件的确切类字符串。大多数遵循模式 A,但时长/缓动各有不同。
Popover(标准配置——进入时长 150ms,ease-out-fluid;退出时长 100ms,ease-in-quart)
data-open:animate-in data-open:duration-150 data-open:[animation-timing-function:var(--ease-out-fluid)]
data-open:fade-in-0 data-open:zoom-in-95
data-closed:animate-out data-closed:duration-100 data-closed:[animation-timing-function:var(--ease-in-quart)]
data-closed:fade-out-0 data-closed:zoom-out-95
data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2
origin-(--transform-origin)
data-entering:will-change-[opacity,transform] data-exiting:will-change-[opacity,transform]
motion-reduce:animate-none motion-reduce:transition-none
Tooltip(时长与 Popover 相同)
data-open:animate-in data-open:duration-150 data-open:[animation-timing-function:var(--ease-out-fluid)]
data-open:fade-in-0 data-open:zoom-in-95
data-closed:animate-out data-closed:duration-100 data-closed:[animation-timing-function:var(--ease-in-quart)]
data-closed:fade-out-0 data-closed:zoom-out-95
data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2
origin-(--transform-origin)
data-entering:will-change-[opacity,transform] data-exiting:will-change-[opacity,transform]
motion-reduce:animate-none motion-reduce:transition-none
Menu/DropdownMenu(进入时长 150ms,退出时长 150ms——两个方向的时长相同)
data-open:animate-in data-open:duration-150 data-open:[animation-timing-function:var(--ease-out-fluid)]
data-closed:animate-out data-closed:duration-150 data-closed:[animation-timing-function:var(--ease-in-quart)]
data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2
origin-(--transform-origin)
data-entering:will-change-[opacity,transform] data-exiting:will-change-[opacity,transform]
motion-reduce:animate-none
Select(duration-150 ease-smooth,不拆分设置时长)
data-open:animate-in data-closed:animate-out
data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2
duration-150 ease-smooth origin-(--transform-origin)
data-entering:will-change-[opacity,transform] data-exiting:will-change-[opacity,transform]
motion-reduce:animate-none
Combobox(进入时长 duration-150,不显式设置退出时长)
data-open:animate-in data-open:duration-150 data-closed:animate-out
data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95
data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2
origin-(--transform-origin)
data-entering:will-change-[opacity,transform] data-exiting:will-change-[opacity,transform]
motion-reduce:animate-none
Context Menu、HoverCard/PreviewCard(duration-100 + 内联方向)
data-open:animate-in data-closed:animate-out
data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95
duration-100
data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2
data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2
data-[side=inline-start]:slide-in-from-right-2 data-[side=inline-end]:slide-in-from-left-2
origin-(--transform-origin) motion-reduce:animate-none
Alert Dialog(duration-100,不滑动、不设置变换原点)
Overlay: data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 duration-100
Content: data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0 data-closed:zoom-out-95 data-open:zoom-in-95 duration-100
Menubar(duration-100,继承 DropdownMenu)
data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 duration-100
+ directional slides + data-[side=inline-start/end] slides
Drawer(仅覆盖层——内容由 vaul 处理)
data-open:animate-in data-closed:animate-out data-closed:fade-out-0 data-open:fade-in-0
10. 特殊组件模式
Checkbox SVG 绘制动画
<path
strokeDasharray={22}
style={{
strokeDashoffset: "var(--check-offset, 66)",
transition: "stroke-dashoffset 200ms linear 50ms",
}}
/>
// Toggle: group-data-checked:[--check-offset:44]
// Unchecked: offset 66 (stroke hidden). Checked: offset 44 (stroke draws on).
// 50ms delay lets background fill animation begin first. Linear for consistent draw speed.
Slider 拖动
cursor-grab data-dragging:cursor-grabbing data-disabled:cursor-default
data-dragging:[&::after]:scale-[0.85] motion-reduce:[&::after]:scale-100
transition-[background-color,transform] duration-250 ease-smooth
[&::after]:transition-transform [&::after]:duration-250 [&::after]:ease-out-fluid
motion-reduce:transition-none [&::after]:motion-reduce:transition-none
NumberField 拖动区域
cursor-ew-resize touch-none
Accordion
data-open:animate-accordion-down data-closed:animate-accordion-up
h-(--accordion-panel-height) data-ending-style:h-0 data-starting-style:h-0
关键帧:0.2s ease-out(在 tokens 中定义为 --animate-accordion-down/up)。
导航菜单(独特的复杂模式)
- 内容:
duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] - 较大的滑动距离:
slide-in-from-right-52、slide-in-from-left-52 - 激活方向样式:
data-starting-style:data-activation-direction=left:translate-x-[-50%]、data-starting-style:data-activation-direction=right:translate-x-[50%] - 内容不透明度:
data-ending-style:opacity-0 data-starting-style:opacity-0 - 定位器:
transition-[top,left,right,bottom] duration-[0.35s] ease-[cubic-bezier(0.22,1,0.36,1)] - 定位器即时模式:
data-instant:transition-none(用于连续悬停) - 弹出框:
origin-(--transform-origin)、data-starting-style:scale-90 data-starting-style:opacity-0 - 弹出框过渡:
transition-[opacity,transform,width,height,scale,translate] duration-[0.35s] - 指示器:
data-[state=visible]:animate-in data-[state=hidden]:animate-out data-[state=hidden]:fade-out data-[state=visible]:fade-in - 切换图标:
transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 - 触发器状态:
transition-[color,background-color,border-color,box-shadow,opacity] duration-150 ease-smooth
开关
- 轨道:
transition-[color,background-color,box-shadow,opacity] duration-250 ease-smooth+motion-safe:active:scale-[0.97]+motion-reduce:transition-none - 滑块:
transition-[margin-inline-start,background-color] duration-300 ease-out-fluid+motion-reduce:transition-none
进度条 / 计量器
- 指示器:
transition-[width,background-color] duration-300 ease-smooth+motion-reduce:transition-none - 不确定状态:
data-[indeterminate]:animate-pulse+motion-reduce:animate-none
较长的状态过渡(与模式 E 的差异)
| 组件 | 属性 | 时长 | 缓动 |
|---|---|---|---|
| 复选框控件 | color,background-color,border-color,box-shadow,transform | 200ms | ease-smooth |
| 组合框项 / 芯片移除 | colors | 200ms | ease-smooth |
| 开关轨道 | color,background-color,box-shadow,opacity | 250ms | ease-smooth |
| 开关滑块 | margin-inline-start,background-color | 300ms | ease-out-fluid |
| 滑块控件 | background-color,transform | 250ms | ease-smooth |
滑块控件 ::after | transform | 250ms | ease-out-fluid |
| 计量器/进度条指示器 | width,background-color | 300ms | ease-smooth |
伪元素过渡
| 组件 | 元素 | 类 |
|---|---|---|
| 按钮(默认) | ::after | after:transition-opacity after:duration-200 |
| 按钮(轮廓) | ::after | after:transition-colors after:duration-200 after:ease-out |
| 标签页(触发器) | ::after | after:transition-opacity + motion-reduce:after:transition-none |
| NavigationMenu | 切换图标 | transition duration-300 group-data-open/navigation-menu-trigger:rotate-180 |
加载 / 不确定状态
- 进度条:
data-[indeterminate]:animate-pulse+motion-reduce:animate-none - Toast:在
Loader2Icon上使用animate-spin(sonner 负责处理 Toast 的所有其他动画)
抽屉 / Toast
动画分别由 vaul 和 sonner 库处理。不要添加自定义动画类,而应配置库的属性。
无动画组件
Avatar、Collapsible、Label、Separator——按设计不包含任何动画、过渡或运动类。
ScrollArea
极简:ScrollBar 上的 transition-colors(使用浏览器默认时长)。
11. 高级技巧
Clip-path
由硬件加速,不会导致布局偏移。clip-path: inset(top right bottom left):
| 值 | 效果 |
|---|---|
inset(0) | 完全可见 |
inset(100%) | 完全隐藏 |
inset(0 0 100% 0) | 从底部隐藏 |
inset(0 100% 0 0) | 从右侧隐藏 |
inset(0 0 50% 0) | 隐藏下半部分 |
inset(50% 0 0 0) | 隐藏上半部分 |
添加圆角:clip-path: inset(0 75% 0 0 round 17px)。
适用场景:图像揭示、对比滑块、文本遮罩效果、标签页过渡、滚动触发的揭示效果、按住删除时的填充效果、主题切换。建议的缓动函数:cubic-bezier(0.77, 0, 0.175, 1)。
模糊衔接
当调整时长和缓动函数后动画仍然不够自然时,可在过渡期间添加 filter: blur(2px)。这能衔接不同状态间的视觉差异,让视觉效果看起来更加平滑。与缩小效果搭配效果很好。目前 Prototyper 的 UI 组件中尚未使用此技巧——可将其作为变形动画的最后手段。
可中断设计
用户应能在动画进行过程中平滑地更改动画状态。若动画在完成前锁定用户操作,会让人觉得响应迟缓。CSS 过渡天然支持中断——可以在首次过渡完成前重新指定结束值,动画会平滑地改变方向。
手势与拖动处理
- 动量速度: 计算方式为
Math.abs(swipeAmount) / timeTaken,阈值设为0.11,即使未达到距离阈值,也可通过快速轻扫关闭 - 阻尼/阻力: 朝受限方向拖动时(例如抽屉位于顶部时向上拖动),逐渐增加阻力——距离越大,移动幅度越小。与 iOS 原生行为一致。
- 滚动与拖动冲突: 仅当内容滚动到顶部时才允许拖动手势。到达顶部后添加 100ms 的超时,以防滚动惯性导致意外关闭。
- 多点触控: 初始触点之后的所有触点都应忽略,直到初始触点释放。这样可防止用户切换手指时元素位置跳动。
- 指针捕获: 滑动或拖动时使用
element.setPointerCapture(event.pointerId),这样指针移出元素边界后仍不会丢失手势。 - 摩擦: 朝“错误”方向拖动时实现阻力,而不是突然停止——这样感觉更自然。
Toast 动画(sonner)
所有 Toast 动画均由 sonner 库处理:
- 进入:
translateY(100%)→translateY(0),400ms 缓动 - 堆叠:绝对定位,使用
translateY(calc(var(--lift-amount) * var(--toasts-before))),缩放系数为0.05 * index - 滑动关闭:动量阈值
0.11、指针捕获、向上拖动时施加摩擦 - 悬停间隙:
:after伪元素填充堆叠 Toast 之间的间隙 - 标签页可见性:通过
visibilitychange,在document.hidden时暂停计时器
不要实现自定义 Toast 动画——请配置 sonner 属性。
Drawer 动画(vaul)
所有 Drawer 动画均由 vaul 库处理:
- 标志性曲线:
cubic-bezier(0.32, 0.72, 0, 1)= 我们的--ease-out-fluid(500ms,与 iOS Sheet 一致) - 背景缩放:根据拖动进度,在页面容器上应用
transform和border-radius - 吸附点:释放时吸附到最近的吸附点,支持视口比例和固定像素值,并基于动量跳过吸附点
- 键盘:Visual Viewport API(
visualViewportresize 事件)会调整抽屉高度
不要添加自定义动画类——请配置 vaul 属性。
工具提示与顺序交互
Base UI 的 TooltipProvider 原生处理工具提示分组:
- 第一个工具提示:加入延迟,防止意外触发
- 同一组中的后续工具提示:完全跳过延迟和动画
- 实现方式:使用
data-instant属性,并设置transition-duration: 0ms