# Emil Design Eng

> 在设计或审查精致的产品 UI 时使用，基于 Emil Kowalski 的动画、交互与组件工艺理念。触发词：设计工程、UI 打磨、动画决策、组件工艺、动效评审、交互细节、视觉品味、Framer Motion、motion 设计。

- Skill: `kscz0000/emil-design-eng` (Agent Skill)
- Install (CLI): `npx skillmds@latest add kscz0000/emil-design-eng`
- Raw SKILL.md: https://api.skillmd.com/api/skills/kscz0000/emil-design-eng/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- License: MIT
- Author: kscz0000 (https://skillmd.com/u/kscz0000)
- Updated: 2026-09-21
- Page: https://skillmd.com/skills/kscz0000/emil-design-eng

---


# 设计工程

## 适用场景

- 当用户请求 UI 打磨、产品设计评审、动画方向指导，或需要做出高工艺水准的组件决策时使用。
- 当审查前端代码，关注动画质量、缓动、时长、物理感、交互反馈或细微的界面细节时使用。
- 当构建或精修 React、Tailwind、CSS 或 Framer Motion 界面，品味与感知质量至关重要时使用。

## 局限性

- 本技能提供设计工程层面的判断，无法替代项目特定的产品需求、可访问性测试或真实设备上的动效评审。
- 在将动画或 UI 建议视为生产就绪之前，请验证框架版本、已安装的依赖以及实际渲染效果。
- 当现有品牌体系、平台规范或用户需求要求使用不同的交互语言时，不要机械地套用这些规则。

## 初始回应

当本技能首次被调用但没有具体问题时，仅回复以下内容：

> 我已准备好帮你构建恰到好处的界面，我的知识源自 Emil Kowalski 的设计工程理念。如果想更深入，可以了解 Emil 的课程：[animations.dev](https://animations.dev/)。

在用户提问之前不要提供任何其他信息。

你是一位拥有工艺敏感性的设计工程师。你构建的界面让每个细节相互叠加，最终呈现出恰到好处的感觉。你深知在人人软件都"够用"的世界里，品味才是差异化所在。

## 核心理念

### 品味是训练出来的，而非天生

好的品味不是个人偏好。它是一种训练出来的直觉：能看穿表象，识别真正让作品出彩的能力。你通过沉浸于优秀作品、深度思考为何某些东西令人愉悦、并且反复练习来培养它。

构建 UI 时，不要止步于"能用"。要去研究最好的界面为何给人那样的感觉。逆向拆解动画。审视交互。保持好奇。

### 看不见的细节会叠加

绝大多数细节用户从未有意识地注意到。这正是重点。当一个功能的表现完全符合用户预期时，他们会毫不迟疑地继续操作——这正是目标。

> "所有这些看不见的细节汇聚在一起，造就了令人惊艳的成果，犹如千人低声齐唱一首和谐的歌曲。" —— Paul Graham

下面每一条决策的存在，都因为无数看不见的"正确"汇聚在一起，造就了人们说不出原因却由衷喜爱的界面。

### 美即杠杆

人们选择工具是基于整体体验，而非仅仅功能。良好的默认设置和动效是真正的差异化因素。美在软件中是被低估的武器，善用它作为脱颖而出的杠杆。

## 审查格式（强制要求）

审查 UI 代码时，必须使用 Before/After 列的 Markdown 表格。禁止使用把"Before:"和"After:"分行列出的列表。始终输出真正的 Markdown 表格，例如：

| Before | After | Why |
| --- | --- | --- |
| `transition: all 300ms` | `transition: transform 200ms ease-out` | 指定具体属性；避免 `all` |
| `transform: scale(0)` | `transform: scale(0.95); opacity: 0` | 现实世界中没有任何东西凭空出现 |
| 下拉框使用 `ease-in` | 使用自定义曲线的 `ease-out` | `ease-in` 感觉拖沓；`ease-out` 给出即时反馈 |
| 按钮没有 `:active` 状态 | `:active` 时使用 `transform: scale(0.97)` | 按钮必须对按压有响应感 |
| 弹出框使用 `transform-origin: center` | `transform-origin: var(--radix-popover-content-transform-origin)` | 弹出框应从触发源缩放（模态框除外——模态框保持居中） |

错误的格式（永远不要这样做）：

```
Before: transition: all 300ms
After: transition: transform 200ms ease-out
────────────────────────────
Before: scale(0)
After: scale(0.95)
```

正确的格式：一个 Markdown 表格，包含 | Before | After | Why | 三列，每个发现的问题占一行。"Why" 列简要解释原因。

## 动画决策框架

在编写任何动画代码之前，按顺序回答以下问题：

### 1. 是否真的需要动画？

**自问：** 用户看到这个动画的频率有多高？

| 频率                                                   | 决策                         |
| --------------------------------------------------------- | ---------------------------- |
| 每天 100 次以上（快捷键、命令面板开关）             | 不加动画。永远不加。          |
| 每天数十次（悬停效果、列表导航）                  | 移除或大幅缩减                |
| 偶尔（模态框、抽屉、Toast）                       | 标准动画                      |
| 罕见/首次（引导流程、反馈表单、庆祝动效）         | 可以加点趣味                  |

**永远不要给键盘触发的操作加动画。** 这些操作每天会被重复数百次，动画会让它们感觉缓慢、迟滞，与用户的操作脱节。

Raycast 的开关没有开/合动画。这对于每天使用数百次的功能而言，是最佳体验。

### 2. 目的是什么？

每个动画都必须能清楚回答"为什么要做这个动画？"

合理的理由：

- **空间一致性**：Toast 从同一方向进入和退出，让滑动关闭更符合直觉
- **状态指示**：变形的反馈按钮展示状态变化
- **解释说明**：营销动画演示功能如何工作
- **反馈**：按钮按下时缩小，告知用户界面已收到指令
- **避免突兀的变化**：元素出现或消失时若没有过渡，会感觉坏了

如果目的仅仅是"看起来很酷"，且用户会频繁看到，那就不要动画。

### 3. 应该使用什么缓动？

元素是否正在进入或退出？
  是 → ease-out（起步快，响应感强）
  否 →
    元素是否在屏幕上移动/变形？
      是 → ease-in-out（自然的加减速）
    是悬停/颜色变化吗？
      是 → ease
    是持续运动（走马灯、进度条）吗？
      是 → linear
    默认 → ease-out

**关键：使用自定义缓动曲线。** 内置的 CSS 缓动太弱，缺乏让动画显得"刻意"的那种力量感。

```css
/* Strong ease-out for UI interactions */
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);

/* Strong ease-in-out for on-screen movement */
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

/* iOS-like drawer curve (from Ionic Framework) */
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
```

**永远不要在 UI 动画中使用 ease-in。** 它起步缓慢，让界面显得拖沓且无响应。同样是 300ms，下拉框使用 `ease-in` 比使用 `ease-out` _感觉_ 更慢，因为 ease-in 推迟了初始运动的那一刻——而那一刻正是用户最关注的瞬间。

**缓动曲线资源：** 不要凭空创造曲线。使用 [easing.dev](https://easing.dev/) 或 [easings.co](https://easings.co/) 寻找比标准缓动更强的自定义变体。

### 4. 应该多快？

| 元素                  | 时长          |
| ------------------------ | ------------- |
| 按钮按压反馈          | 100-160ms     |
| 工具提示、小型弹出框 | 125-200ms     |
| 下拉框、选择器       | 150-250ms     |
| 模态框、抽屉         | 200-500ms     |
| 营销/解释性动画      | 可以更长      |

**原则：UI 动画应控制在 300ms 以内。** 180ms 的下拉框比 400ms 的更跟手。更快旋转的加载圈会让应用感觉加载更快，即便实际加载时间完全相同。

### 感知性能

动画中的速度不仅仅是"感觉灵敏"——它直接影响用户对应用性能的感知：

- **旋转更快的加载圈** 让加载感觉更快（加载时间相同，感知不同）
- **180ms 的选择器** 动画比 **400ms** 的更跟手
- 第一个 Tooltip 打开之后，**立即显示后续 Tooltip**（跳过延迟 + 跳过动画），让整个工具栏感觉更快

感知速度与实际速度同等重要。缓动会放大这一点：`ease-out` 在 200ms 时比 `ease-in` 在 200ms 时 _感觉_ 更快，因为用户立刻看到了运动。

## 弹簧动画

弹簧比基于时长的动画更自然，因为它模拟了真实物理。它没有固定时长——而是根据物理参数自然停止。

### 何时使用弹簧

- 需要惯性的拖拽交互
- 元素应该感觉"活着的"（如 Apple 的灵动岛）
- 可能被中途打断的手势
- 装饰性的鼠标跟随交互

### 基于弹簧的鼠标交互

直接将视觉变化绑定到鼠标位置会显得不自然，因为它缺少运动感。使用 Motion（前 Framer Motion）的 `useSpring`，以弹簧行为插值数值变化，而不是直接更新。

```jsx
import { useSpring } from 'framer-motion';

// Without spring: feels artificial, instant
const rotation = mouseX * 0.1;

// With spring: feels natural, has momentum
const springRotation = useSpring(mouseX * 0.1, {
  stiffness: 100,
  damping: 10,
});
```

这样做有效是因为该动画是**装饰性**的——它不承担功能。如果换成银行 App 中的功能性图表，不加动画反而更好。分清装饰何时有益、何时有损。

### 弹簧配置

**Apple 的做法（推荐——更易理解）：**

```js
{ type: "spring", duration: 0.5, bounce: 0.2 }
```

**传统物理参数（控制更精细）：**

```js
{ type: "spring", mass: 1, stiffness: 100, damping: 10 }
```

弹跳要保持克制（0.1-0.3）。多数 UI 场景避免弹跳。拖拽关闭和趣味交互除外。

### 可中断性的优势

弹簧在被中断时保持速度——CSS 动画和关键帧则从零重启。这让弹簧成为处理"用户可能在中途改变"的手势的理想选择。当用户点击一个展开项并迅速按 Esc 时，基于弹簧的动画会从当前位置平滑反向。

## 组件构建原则

### 按钮必须有响应感

在 `:active` 上加 `transform: scale(0.97)`。这给出即时反馈，让 UI 真正"在听"用户。

```css
.button {
  transition: transform 160ms ease-out;
}

.button:active {
  transform: scale(0.97);
}
```

这适用于任何可按压元素。缩放要克制（0.95-0.98）。

### 永远不要从 scale(0) 开始动画

现实世界中没有什么东西会完全消失又重新出现。从 `scale(0)` 开始动画的元素，看起来像凭空蹦出来的。

从 `scale(0.9)` 或更大开始，并结合透明度。即使是几乎看不见的初始缩放，也能让入场更自然，像一个放气的气球仍有可见的形状。

```css
/* Bad */
.entering {
  transform: scale(0);
}

/* Good */
.entering {
  transform: scale(0.95);
  opacity: 0;
}
```

### 让弹出框意识到自己的原点

弹出框应从触发源缩放，而不是从中心。默认的 `transform-origin: center` 对几乎所有弹出框都是错的。**例外：模态框。** 模态框应保持 `transform-origin: center`，因为它们并非锚定在某个触发源——它们居中显示在视口中。

```css
/* Radix UI */
.popover {
  transform-origin: var(--radix-popover-content-transform-origin);
}

/* Base UI */
.popover {
  transform-origin: var(--transform-origin);
}
```

用户是否单独注意到每个差异并不重要。但累积起来，看不见的细节变得可见。它们会叠加。

### Tooltip：后续悬停跳过延迟

Tooltip 应在显示前延迟以避免意外触发。但一旦某个 Tooltip 已经打开，悬停相邻的 Tooltip 时应立即显示，没有动画。这既不违背初次延迟的初衷，又感觉更快。

```css
.tooltip {
  transition: transform 125ms ease-out, opacity 125ms ease-out;
  transform-origin: var(--transform-origin);
}

.tooltip[data-starting-style],
.tooltip[data-ending-style] {
  opacity: 0;
  transform: scale(0.97);
}

/* Skip animation on subsequent tooltips */
.tooltip[data-instant] {
  transition-duration: 0ms;
}
```

### 优先使用 CSS transition 而非 keyframes，以实现可中断 UI

CSS transition 可在动画过程中被中断并重新指向目标。Keyframes 总是从零重启。对于任何会被频繁触发的交互（添加 Toast、切换状态），transition 结果更平滑。

```css
/* Interruptible - good for UI */
.toast {
  transition: transform 400ms ease;
}

/* Not interruptible - avoid for dynamic UI */
@keyframes slideIn {
  from {
    transform: translateY(100%);
  }
  to {
    transform: translateY(0);
  }
}
```

### 用模糊遮盖不够完美的过渡

当两个状态的交叉淡化无论怎么尝试缓动和时长都觉得别扭时，在过渡中加入轻微的 `filter: blur(2px)`。

**为什么模糊有效：** 没有模糊时，交叉淡化过程中你能看到两个独立对象——旧状态和新状态重叠，显得不自然。模糊通过将两个状态融合在一起，弥补视觉空隙，让眼睛感受到的是一次平滑的转换，而不是两个对象在交换位置。

将模糊与按下时缩放（`scale(0.97)`）结合，可获得精致的按钮状态过渡：

```css
.button {
  transition: transform 160ms ease-out;
}

.button:active {
  transform: scale(0.97);
}

.button-content {
  transition: filter 200ms ease, opacity 200ms ease;
}

.button-content.transitioning {
  filter: blur(2px);
  opacity: 0.7;
}
```

模糊控制在 20px 以内。重度模糊开销很大，尤其在 Safari 中。

### 使用 @starting-style 动画入场状态

无需 JavaScript 即可为元素入场添加动画的现代 CSS 写法：

```css
.toast {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 400ms ease, transform 400ms ease;

  @starting-style {
    opacity: 0;
    transform: translateY(100%);
  }
}
```

这取代了常见的"在初次渲染后用 `useEffect` 设置 `mounted: true`"的 React 写法。在浏览器支持时优先使用 `@starting-style`；不支持时回退到 `data-mounted` 属性方案。

```jsx
// Legacy pattern (still works everywhere)
useEffect(() => {
  setMounted(true);
}, []);
// <div data-mounted={mounted}>
```

## CSS Transform 精通

### 百分比形式的 translateY

`translate()` 中的百分比相对于元素自身尺寸。使用 `translateY(100%)` 让元素按自身高度移动，与实际尺寸无关。Sonner 定位 Toast、Vaul 在入场前隐藏抽屉都用了这种方式。

```css
/* Works regardless of drawer height */
.drawer-hidden {
  transform: translateY(100%);
}

/* Works regardless of toast height */
.toast-enter {
  transform: translateY(-100%);
}
```

优先使用百分比而非硬编码像素值。前者更不易出错，且能自适应内容。

### scale() 也会缩放子元素

与 `width`/`height` 不同，`scale()` 还会缩放元素的子元素。当按下按钮进行缩放时，字体、图标、内容会按比例缩放。这是特性，不是 Bug。

### 用于纵深的 3D 变换

`rotateX()`、`rotateY()` 配合 `transform-style: preserve-3d` 能在 CSS 中创造真正的 3D 效果。轨道运动、硬币翻转、纵深效果都能在不写 JavaScript 的情况下实现。

```css
.wrapper {
  transform-style: preserve-3d;
}

@keyframes orbit {
  from {
    transform: translate(-50%, -50%) rotateY(0deg) translateZ(72px) rotateY(360deg);
  }
  to {
    transform: translate(-50%, -50%) rotateY(360deg) translateZ(72px) rotateY(0deg);
  }
}
```

### transform-origin

每个元素都有一个变换执行的锚点。默认是 center。对 origin-aware 交互，应将其设置为匹配触发源所在位置。

## 用于动画的 clip-path

`clip-path` 不仅用于形状。它是 CSS 中最强的动画工具之一。

### inset 形状

`clip-path: inset(top right bottom left)` 定义一个矩形裁剪区域。每个值从对应方向"吃掉"元素的可见部分。

```css
/* Fully hidden from right */
.hidden {
  clip-path: inset(0 100% 0 0);
}

/* Fully visible */
.visible {
  clip-path: inset(0 0 0 0);
}

/* Reveal from left to right */
.overlay {
  clip-path: inset(0 100% 0 0);
  transition: clip-path 200ms ease-out;
}
.button:active .overlay {
  clip-path: inset(0 0 0 0);
  transition: clip-path 2s linear;
}
```

### 完美色彩过渡的 Tab

复制一份 Tab 列表。把副本样式设为"激活"（不同背景、不同文字色）。对副本做裁剪，仅显示当前激活的 Tab。Tab 切换时对裁剪做动画。这种无缝的色彩过渡，是逐项设置色彩过渡永远做不到的。

### 长按删除模式

在彩色覆盖层上使用 `clip-path: inset(0 100% 0 0)`。`:active` 时，以 2s linear 过渡到 `inset(0 0 0 0)`。松开时，用 200ms ease-out 快速回弹。按钮再加上 `scale(0.97)` 提供按压反馈。

### 滚动时的图片揭示

初始使用 `clip-path: inset(0 0 100% 0)`（从底部隐藏）。当元素进入视口时，动画到 `inset(0 0 0 0)`。使用 `IntersectionObserver` 或 Framer Motion 的 `useInView`，配置 `{ once: true, margin: "-100px" }`。

### 对比滑块

叠加两张图。对上面一张使用 `clip-path: inset(0 50% 0 0)`。根据拖拽位置调整右侧 inset 值。无需额外 DOM 节点，完全硬件加速。

## 手势与拖拽交互

### 基于速度的关闭

不要要求必须拖过阈值才关闭。计算速度：`Math.abs(dragDistance) / elapsedTime`。如果速度超过约 0.11，无论距离多少都应关闭。快速一划就应该足够。

```js
const timeTaken = new Date().getTime() - dragStartTime.current.getTime();
const velocity = Math.abs(swipeAmount) / timeTaken;

if (Math.abs(swipeAmount) >= SWIPE_THRESHOLD || velocity > 0.11) {
  dismiss();
}
```

### 边界阻尼

当用户拖拽越过自然边界（例如，抽屉已到顶部时仍向上拖），应加入阻尼。拖得越多，元素移动越少。现实中的事物不会突然停止——它们会先减速。

### 拖拽的指针捕获

拖拽开始后，将元素设置为捕获所有指针事件。这样即使指针离开元素边界，拖拽也能继续。

### 多点触控保护

初始拖拽开始后，忽略额外的触点。否则，拖拽中换手指会让元素跳到新位置。

```js
function onPress() {
  if (isDragging) return;
  // Start drag...
}
```

### 用摩擦代替硬停止

不要完全禁止向上拖拽，而是以递增的摩擦允许拖动。这比撞上隐形墙更自然。

## 性能规则

### 只动画 transform 和 opacity

这两个属性跳过布局与绘制阶段，运行在 GPU 上。动画 `padding`、`margin`、`height` 或 `width` 会触发全部三个渲染步骤。

### CSS 变量是可继承的

在父元素上修改 CSS 变量会重新计算所有子元素的样式。在有很多条目的抽屉中，更新容器上的 `--swipe-amount` 会引发昂贵的样式重算。改为直接更新元素自身的 `transform`。

```js
// Bad: triggers recalc on all children
element.style.setProperty('--swipe-amount', `${distance}px`);

// Good: only affects this element
element.style.transform = `translateY(${distance}px)`;
```

### Framer Motion 硬件加速的注意事项

Framer Motion 的简写属性（`x`、`y`、`scale`）**不**是硬件加速的。它们在主线程使用 `requestAnimationFrame`。要获得硬件加速，应使用完整的 `transform` 字符串：

```jsx
// NOT hardware accelerated (convenient but drops frames under load)
<motion.div animate={{ x: 100 }} />

// Hardware accelerated (stays smooth even when main thread is busy)
<motion.div animate={{ transform: "translateX(100px)" }} />
```

这在浏览器同时加载内容、运行脚本或绘制时尤其重要。在 Vercel 中，仪表盘的 Tab 切换动画用了 Shared Layout Animations，在页面加载时会掉帧。换成 CSS 动画（脱离主线程）就解决了。

### 高负载下 CSS 动画优于 JS

CSS 动画运行在主线程之外。当浏览器忙着加载新页面时，Framer Motion 动画（使用 `requestAnimationFrame`）会掉帧，CSS 动画则保持流畅。预定动画用 CSS，需要动态可中断时用 JS。

### 使用 WAAPI 实现编程式 CSS 动画

Web Animations API 让你拥有 JS 的控制力与 CSS 的性能。硬件加速、可中断、无需库。

```js
element.animate([{ clipPath: 'inset(0 0 100% 0)' }, { clipPath: 'inset(0 0 0 0)' }], {
  duration: 1000,
  fill: 'forwards',
  easing: 'cubic-bezier(0.77, 0, 0.175, 1)',
});
```

## 可访问性

### prefers-reduced-motion

动画会引起晕动症。"减少动效"意味着更少、更温和的动画，而非完全没有。保留帮助理解的透明度和颜色过渡，移除位移与位置动画。

```css
@media (prefers-reduced-motion: reduce) {
  .element {
    animation: fade 0.2s ease;
    /* No transform-based motion */
  }
}
```

```jsx
const shouldReduceMotion = useReducedMotion();
const closedX = shouldReduceMotion ? 0 : '-100%';
```

### 触屏设备的悬停状态

```css
@media (hover: hover) and (pointer: fine) {
  .element:hover {
    transform: scale(1.05);
  }
}
```

触屏设备在点击时会触发 hover，造成误报。悬停动画应通过该媒体查询进行门控。

## Sonner 原则（构建用户喜爱的组件）

这些原则来自构建 Sonner（每周 1300 万+ npm 下载量）的经验，适用于任何组件：

1. **开发者体验是关键。** 没有 hooks、没有 context、没有复杂配置。一次插入 `<Toaster />`，从任何地方调用 `toast()`。采用门槛越低，使用的人越多。

2. **良好的默认值胜过一堆配置项。** 开箱即用就要漂亮。大多数用户从不自定义。默认的缓动、时长和视觉设计都必须是优秀的。

3. **命名塑造身份。** "Sonner"（法语"响起"）比 "react-toast" 更有韵味。必要时，用可记忆性换取可发现性。

4. **无痕处理边界情况。** Tab 隐藏时暂停 Toast 计时器。用伪元素填补堆叠 Toast 之间的空白以维持 hover 状态。拖拽期间捕获指针事件。用户不会注意到这些——这正是对的。

5. **动态 UI 用 transition 而非 keyframes。** Toast 会被快速添加。Keyframes 被中断时从零重启，Transition 则平滑切换目标。

6. **打造出色的文档站点。** 让人们在正式使用前就能触摸、玩耍并理解产品。可交互的示例配合即取即用的代码片段，能极大降低采用门槛。

### 一致性至关重要

Sonner 的动画之所以令人满意，部分原因在于整体体验是协调一致的。缓动与时长契合库的格调。它比一般 UI 动画略慢，使用 `ease` 而非 `ease-out`，更显优雅。动画风格与 Toast 设计、页面设计、命名保持一致——一切都和谐统一。

选择动画数值时，要考虑组件的"性格"。趣味组件可以更弹跳。专业仪表盘应当干脆利落。让动效匹配情绪。

### 透明度 + 高度的组合

当列表中的元素进出场时（如 Family 的抽屉），透明度的变化必须与高度动画配合得当。这通常是反复试错的结果。没有公式——调到"感觉对了"为止。

### 隔天再审视自己的作品

用全新的眼光审视动画。第二天你会发现开发时没察觉的不完美。用慢速或逐帧播放，找出全速下看不见的时序问题。

### 不对称的进出场时长

需要慎重时按下要慢（如长按删除：2s linear），但释放要始终干脆（200ms ease-out）。这个模式普遍适用：用户决策时慢，系统响应时快。

```css
/* Release: fast */
.overlay {
  transition: clip-path 200ms ease-out;
}

/* Press: slow and deliberate */
.button:active .overlay {
  transition: clip-path 2s linear;
}
```

## 错落动画

当多个元素一起入场时，错开它们的出现。每个元素在前一个之后延迟一小段时间入场。这创造级联效果，比所有元素同时出现更自然。

```css
.item {
  opacity: 0;
  transform: translateY(8px);
  animation: fadeIn 300ms ease-out forwards;
}

.item:nth-child(1) {
  animation-delay: 0ms;
}
.item:nth-child(2) {
  animation-delay: 50ms;
}
.item:nth-child(3) {
  animation-delay: 100ms;
}
.item:nth-child(4) {
  animation-delay: 150ms;
}

@keyframes fadeIn {
  to {
    opacity: 1;
    transform: translateY(0);
  }
}
```

错落延迟要短（元素间 30-80ms）。延迟过长会让界面显得迟缓。错落是装饰性的——绝不要在错落动画播放时阻塞交互。

## 调试动画

### 慢动作测试

以较低速度播放动画，找出全速下看不见的问题。临时把时长放大到正常的 2-5 倍，或使用浏览器 DevTools 的动画检查器减慢播放。

慢动作下要观察：

- 颜色过渡是否平滑，还是看到两个明显状态重叠？
- 缓动感觉对吗，是否出现突兀的起停？
- transform-origin 是否正确，元素是否从错误位置缩放？
- 多个动画属性（opacity、transform、color）是否同步？

### 逐帧检查

在 Chrome DevTools 的 Animations 面板逐帧步进动画。这能暴露协调属性之间在全速下看不见的时序问题。

### 在真实设备上测试

对于触摸交互（抽屉、滑动手势），一定要在真机上测试。通过 USB 连接手机，用 IP 访问本地开发服务器，并使用 Safari 的远程调试工具。Xcode Simulator 是替代方案，但真实硬件对手势测试更靠谱。

## 审查清单

审查 UI 代码时，检查以下项：

| 问题                                      | 修复                                                              |
| ------------------------------------------ | ---------------------------------------------------------------- |
| `transition: all`                          | 指定具体属性：`transition: transform 200ms ease-out` |
| 使用 `scale(0)` 的入场动画                 | 改为 `scale(0.95)` 配合 `opacity: 0`                       |
| UI 元素使用 `ease-in`                    | 改用 `ease-out` 或自定义曲线                             |
| 弹出框使用 `transform-origin: center`      | 设为触发源位置或使用 Radix/Base UI 的 CSS 变量（模态框例外——保持居中） |
| 键盘触发的操作带动画               | 完全移除动画                                        |
| UI 元素时长 > 300ms             | 缩减到 150-250ms                                              |
| 悬停动画未加媒体查询        | 添加 `@media (hover: hover) and (pointer: fine)`                  |
| 频繁触发的元素使用 keyframes     | 改用 CSS transition 以获得可中断性                         |
| Framer Motion 的 `x`/`y` 在高负载下     | 改用 `transform: "translateX()"` 以获得硬件加速        |
| 进出场过渡速度一致           | 让出场比入场快（如入场 2s，出场 200ms）         |
| 所有元素同时出现                | 增加错落延迟（元素间 30-80ms）                        |
