fluid-design

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

10 Principles for Fluid Interfaces

流畅界面的10条原则

A fluid interface feels like it has physical weight, momentum, and awareness. It responds to your input as if it were a real object — not a series of state changes rendered to screen. These 10 principles define the difference between an interface that works and one that feels right.
Each principle includes the underlying concept, why it matters, and how to implement it with specific tools.

流畅的界面让人感觉具备物理重量、惯性和感知能力,它会对你的输入做出响应,就像真实物体一样,而不是一系列渲染到屏幕上的状态变化。这10条原则定义了「能用」的界面和「好用」的界面之间的区别。
每条原则都包含底层概念、重要性说明,以及具体工具的实现方式。

Principle 1: Motion Should Be Physics-Based, Not Time-Based

原则1:动效应基于物理而非时间

Traditional CSS transitions use fixed durations and easing curves. The result is motion that feels mechanical — every animation takes exactly 300ms regardless of how far an element travels or how fast the user was moving when they released it. Physics-based motion uses spring dynamics instead: tension, friction, and mass determine how an element moves. This means a small nudge produces a gentle settle, while a fast flick produces an energetic overshoot. The motion adapts to the context that produced it.
Why it matters: Spring animations feel natural because they model how real objects behave. They have no fixed duration — they resolve when the energy dissipates. This eliminates the uncanny disconnect between user input velocity and animation response.
Implementation:
Framer Motion's
spring
type is the most accessible entry point. For lower-level control, use Popmotion or build on
requestAnimationFrame
with spring physics.
jsx
// Framer Motion — spring is the default for physical properties
<motion.div
  animate={{ x: targetX }}
  transition={{
    type: "spring",
    stiffness: 300,  // How tight the spring pulls
    damping: 25,     // How quickly oscillation settles
    mass: 0.8,       // How heavy the element feels
  }}
/>
javascript
// Vanilla JS — spring physics on rAF
function springAnimation(current, target, velocity, { stiffness = 300, damping = 25, mass = 1 }) {
  const force = -stiffness * (current - target);
  const dampingForce = -damping * velocity;
  const acceleration = (force + dampingForce) / mass;
  const newVelocity = velocity + acceleration * (1 / 60);
  const newPosition = current + newVelocity * (1 / 60);
  return { position: newPosition, velocity: newVelocity };
}
Key tuning values: Stiffness 200–400 for responsive UI elements. Damping 20–30 for a natural settle with minimal overshoot. Reduce mass below 1.0 for elements that should feel lightweight and nimble (toggles, chips), increase above 1.0 for elements that should feel substantial (modals, sheets).
Tools: Framer Motion, React Spring, Popmotion, Motion One, SwiftUI's
.spring()
.

传统的CSS transitions使用固定时长和缓动曲线,最终的动效会显得非常机械:无论元素移动距离有多远,或是用户释放时的速度有多快,每个动画都恰好耗时300ms。基于物理的动效则使用弹簧动力学:张力、摩擦力和质量决定了元素的运动方式,这意味着轻微的推动会让元素缓慢归位,快速的滑动则会产生更有活力的回弹效果,动效会根据触发场景自适应调整。
为什么重要: 弹簧动画之所以自然,是因为它模拟了真实物体的运动规律,没有固定时长,会在能量耗尽时自动结束,这就消除了用户输入速度和动画响应之间的违和感。
实现方式:
Framer Motion的
spring
类型是最易上手的实现方案。如果需要更低层级的控制,可以使用Popmotion,或者基于
requestAnimationFrame
自行实现弹簧物理效果。
jsx
// Framer Motion — spring是物理属性的默认过渡类型
<motion.div
  animate={{ x: targetX }}
  transition={{
    type: "spring",
    stiffness: 300,  // 弹簧的拉力强度
    damping: 25,     // 振动衰减的速度
    mass: 0.8,       // 元素的重量感
  }}
/>
javascript
// Vanilla JS — 基于rAF实现的弹簧物理效果
function springAnimation(current, target, velocity, { stiffness = 300, damping = 25, mass = 1 }) {
  const force = -stiffness * (current - target);
  const dampingForce = -damping * velocity;
  const acceleration = (force + dampingForce) / mass;
  const newVelocity = velocity + acceleration * (1 / 60);
  const newPosition = current + newVelocity * (1 / 60);
  return { position: newPosition, velocity: newVelocity };
}
核心调优值: 响应式UI元素的刚度建议设为200-400,阻尼建议设为20-30,可以实现自然归位和最小回弹效果。重量低于1.0时元素会显得轻盈灵活(开关、标签),高于1.0时会显得更有分量(模态框、底部弹窗)。
工具: Framer Motion, React Spring, Popmotion, Motion One, SwiftUI的
.spring()

Principle 2: Every Animation Must Be Interruptible

原则2:所有动画都必须可中断

If a user taps a button while a modal is still animating open, the modal should reverse smoothly from its current position — not finish opening, then close. If a user starts dragging a card mid-bounce, the card should immediately respond to their finger. Non-interruptible animations create a fundamental disconnect: the interface is doing something the user didn't ask for, and they have to wait.
Why it matters: Interruptibility is the single biggest factor in whether an interface feels responsive or sluggish. A 400ms animation that can be interrupted at any point feels faster than a 200ms animation that locks out input.
Implementation:
Spring animations are inherently interruptible — you simply change the target and the spring recalculates from its current position and velocity. CSS animations and keyframes are not interruptible by default; you need to read the computed style and restart from there.
jsx
// Framer Motion — interruptible by default
// Changing `isOpen` mid-animation reverses smoothly
<motion.div
  animate={{ height: isOpen ? "auto" : 0 }}
  transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
css
/* CSS approach — use transitions, not keyframes, for interruptibility */
.panel {
  transition: transform 250ms ease-out;
  /* Changing the class mid-transition reverses from current position */
}
.panel.open { transform: translateY(0); }
.panel.closed { transform: translateY(100%); }
Rule of thumb: If the user can trigger a state change while an animation is playing, that animation must be interruptible. If you're using
@keyframes
for interactive elements, reconsider.
Tools: Framer Motion, React Spring (both handle this natively). For CSS, prefer
transition
over
@keyframes
for any user-triggered state change.

如果用户在模态框还在执行打开动画时点击了按钮,模态框应该从当前位置平滑反向关闭,而不是先完成打开动画再关闭。如果用户在卡片回弹过程中开始拖拽,卡片应该立即响应手指的操作。不可中断的动画会造成根本性的违和感:界面在执行用户没有要求的操作,用户只能被动等待。
为什么重要: 可中断性是决定界面响应快慢的最核心因素,一个可以随时中断的400ms动画,比一个会锁定输入的200ms动画感觉更快。
实现方式:
弹簧动画天然支持可中断性,你只需要修改目标值,弹簧就会从当前位置和速度开始重新计算。CSS动画和keyframes默认不支持可中断,你需要读取计算后的样式,从当前状态重新开始动画。
jsx
// Framer Motion — 默认支持可中断
// 动画过程中修改`isOpen`会平滑反向过渡
<motion.div
  animate={{ height: isOpen ? "auto" : 0 }}
  transition={{ type: "spring", stiffness: 350, damping: 30 }}
/>
css
/* CSS实现方案 — 使用transition而非keyframes来实现可中断性 */
.panel {
  transition: transform 250ms ease-out;
  /* 过渡过程中修改类名会从当前位置反向过渡 */
}
.panel.open { transform: translateY(0); }
.panel.closed { transform: translateY(100%); }
经验法则: 如果用户可以在动画播放过程中触发状态变化,那么这个动画必须支持可中断。如果你要给交互元素使用
@keyframes
,请重新考虑。
工具: Framer Motion, React Spring(两者都原生支持该特性)。CSS场景下,所有用户触发的状态变化优先使用
transition
而非
@keyframes

Principle 3: Direct Manipulation Over Indirect Control

原则3:优先直接操纵而非间接控制

Wherever possible, let users move, resize, reorder, and dismiss elements by directly manipulating them — dragging, swiping, pinching — rather than pressing buttons that trigger those actions. A drag-to-dismiss sheet feels fundamentally different from tapping a close button. Direct manipulation creates a sense of ownership and physical connection to the interface.
Why it matters: Direct manipulation collapses the gap between intention and outcome. The user doesn't tell the interface what to do; they do it themselves. This makes interactions feel immediate and intuitive, especially on touch devices.
Implementation:
Track pointer position and velocity. Map pointer movement directly to element position (1:1 tracking during the gesture). On release, use the pointer's velocity to determine the outcome — a fast swipe dismisses, a slow release snaps back.
jsx
// Framer Motion — drag with velocity-based snap/dismiss
<motion.div
  drag="y"
  dragConstraints={{ top: 0, bottom: 0 }}
  dragElastic={0.2}
  onDragEnd={(_, info) => {
    // Velocity-based decision: fast swipe = dismiss
    if (info.velocity.y > 500 || info.offset.y > 200) {
      onDismiss();
    }
  }}
/>
javascript
// Vanilla JS — pointer tracking with velocity
let lastY = 0, lastTime = 0, velocity = 0;

element.addEventListener('pointermove', (e) => {
  const now = performance.now();
  velocity = (e.clientY - lastY) / (now - lastTime);
  lastY = e.clientY;
  lastTime = now;

  // 1:1 tracking — element follows the pointer exactly
  element.style.transform = `translateY(${e.clientY - startY}px)`;
});

element.addEventListener('pointerup', () => {
  if (Math.abs(velocity) > 0.5) dismiss();
  else snapBack();
});
Where to apply: Bottom sheets, drawers, cards in a stack, reorderable lists, image galleries, dismissable notifications, sliders, and any element the user might instinctively try to grab.
Tools: Framer Motion (
drag
),
@use-gesture/react
for complex gesture recognition, Pragmatic Drag and Drop for reordering, native CSS
touch-action
for gesture control.

只要有可能,尽量让用户通过直接操纵来移动、调整大小、重排序、关闭元素——拖拽、滑动、捏合,而不是通过点击按钮来触发这些操作。拖拽关闭的底部弹窗和点击关闭按钮的体验有本质区别,直接操纵能让用户产生对界面的掌控感和物理连接感。
为什么重要: 直接操纵缩小了意图和结果之间的差距,用户不需要「告诉」界面做什么,而是「自己直接操作」,这会让交互感觉更即时、更直观,在触摸设备上尤其明显。
实现方式:
追踪指针位置和速度,将指针移动直接映射到元素位置(手势过程中1:1跟随)。释放时根据指针的速度决定最终结果:快速滑动就关闭,慢速释放就回弹归位。
jsx
// Framer Motion — 基于速度的拖拽吸附/关闭
<motion.div
  drag="y"
  dragConstraints={{ top: 0, bottom: 0 }}
  dragElastic={0.2}
  onDragEnd={(_, info) => {
    // 基于速度判断:快速滑动 = 关闭
    if (info.velocity.y > 500 || info.offset.y > 200) {
      onDismiss();
    }
  }}
/>
javascript
// Vanilla JS — 带速度计算的指针追踪
let lastY = 0, lastTime = 0, velocity = 0;

element.addEventListener('pointermove', (e) => {
  const now = performance.now();
  velocity = (e.clientY - lastY) / (now - lastTime);
  lastY = e.clientY;
  lastTime = now;

  // 1:1跟随 — 元素完全跟随指针移动
  element.style.transform = `translateY(${e.clientY - startY}px)`;
});

element.addEventListener('pointerup', () => {
  if (Math.abs(velocity) > 0.5) dismiss();
  else snapBack();
});
适用场景: 底部弹窗、抽屉、堆叠卡片、可排序列表、图片画廊、可关闭通知、滑块,以及任何用户会本能想要抓取的元素。
工具: Framer Motion(
drag
属性)、复杂手势识别用
@use-gesture/react
、重排序用Pragmatic Drag and Drop、手势控制用原生CSS
touch-action

Principle 4: Preserve Velocity Across Gesture Boundaries

原则4:跨手势边界保留速度

When a user releases a dragged element, the element should continue moving at the velocity it had at the moment of release — not stop dead and then animate to its destination. This is momentum. Similarly, when an element snaps to a position, the snap animation should inherit the gesture's velocity as its initial velocity.
Why it matters: Velocity preservation is what makes the difference between an interface that feels like you're manipulating objects and one that feels like you're toggling states. It's the bridge between the gesture (direct manipulation) and the animation (system response).
Implementation:
Capture the pointer velocity at the moment of release. Pass it as the
initialVelocity
to your spring animation. The spring then starts with that energy and dissipates it naturally.
jsx
// Framer Motion — velocity is preserved automatically during drag
// For manual control:
const y = useMotionValue(0);

function onPointerUp(velocity) {
  animate(y, snapPoint, {
    type: "spring",
    velocity: velocity,  // Inherit gesture velocity
    stiffness: 300,
    damping: 30,
  });
}
The test: Flick an element quickly toward its target. It should overshoot slightly and settle back — because it arrived with excess energy. Drag it slowly and release near the target. It should glide in gently with no overshoot. If both feel the same, velocity isn't being preserved.
Tools: Framer Motion and React Spring both support
velocity
as an animation parameter. For vanilla implementations, track
dx/dt
during the gesture and feed it into your spring function.

当用户释放拖拽的元素时,元素应该继续保持释放瞬间的速度移动,而不是突然停下再动画到目标位置,这就是惯性。同样,当元素吸附到某个位置时,吸附动画应该继承手势的速度作为初始速度。
为什么重要: 速度保留是「像操纵真实物体一样的界面」和「只是切换状态的界面」的核心区别,它是手势(直接操纵)和动画(系统响应)之间的桥梁。
实现方式:
在释放瞬间捕获指针速度,将其作为
initialVelocity
传入弹簧动画,弹簧就会以该初始能量开始运动,然后自然衰减。
jsx
// Framer Motion — 拖拽过程中会自动保留速度
// 手动控制示例:
const y = useMotionValue(0);

function onPointerUp(velocity) {
  animate(y, snapPoint, {
    type: "spring",
    velocity: velocity,  // 继承手势速度
    stiffness: 300,
    damping: 30,
  });
}
测试方法: 快速向目标位置滑动元素,它应该会略微超出目标位置再回弹归位,因为它带着多余的能量到达目标点。缓慢拖拽到目标位置附近释放,它应该会平缓归位,没有回弹。如果两种情况的表现一致,说明没有保留速度。
工具: Framer Motion和React Spring都支持
velocity
作为动画参数。原生实现的话,在手势过程中追踪
dx/dt
,然后传入你的弹簧函数即可。

Principle 5: Use Shared Element Transitions to Maintain Spatial Context

原则5:使用共享元素转场保留空间上下文

When navigating between views, elements that exist in both views should transition continuously rather than disappearing and reappearing. A thumbnail that expands into a full image, a list item that morphs into a detail view, a button that becomes a modal — these shared element transitions maintain spatial context and help users understand where they are.
Why it matters: Without spatial transitions, every navigation feels like a hard cut. The user loses their sense of place. Shared element transitions communicate that the new view is an expansion of what they were looking at, not a replacement.
Implementation:
The View Transitions API is the modern standard for cross-view transitions. For component-level transitions within a single page, use
layoutId
in Framer Motion or FLIP (First, Last, Invert, Play) techniques.
jsx
// Framer Motion — shared layout animation via layoutId
// List view
<motion.div layoutId={`card-${item.id}`}>
  <Thumbnail />
</motion.div>

// Detail view — same layoutId, Framer animates between them
<motion.div layoutId={`card-${item.id}`}>
  <FullImage />
</motion.div>
javascript
// View Transitions API — cross-page or cross-route transitions
// Assign matching view-transition-name values to shared elements
// CSS:
// .thumbnail { view-transition-name: hero-image; }
// .full-image { view-transition-name: hero-image; }

document.startViewTransition(() => {
  updateDOM(); // Swap the views
});
Design rule: Identify elements that persist across views and give them continuous transitions. Everything else can fade or slide as a group. The persistent elements anchor the user's spatial understanding.
Tools: View Transitions API (native), Framer Motion (
layoutId
,
AnimatePresence
), FLIP technique (manual), Navigation API for MPA transitions.

在视图之间导航时,两个视图中都存在的元素应该连续过渡,而不是消失再重新出现。缩略图扩展为完整图片、列表项变形为详情页、按钮变为模态框——这些共享元素转场可以保留空间上下文,帮助用户理解当前所处的位置。
为什么重要: 没有空间过渡的话,每次导航都像是硬切,用户会失去位置感。共享元素转场可以传达出新视图是用户正在查看的内容的「扩展」,而不是完全的替换。
实现方式:
View Transitions API是跨视图过渡的现代标准。单页内的组件级过渡可以使用Framer Motion的
layoutId
或者FLIP(First, Last, Invert, Play)技术。
jsx
// Framer Motion — 通过layoutId实现共享布局动画
// 列表视图
<motion.div layoutId={`card-${item.id}`}>
  <Thumbnail />
</motion.div>

// 详情视图 — 相同的layoutId,Framer会自动在两者之间做过渡
<motion.div layoutId={`card-${item.id}`}>
  <FullImage />
</motion.div>
javascript
// View Transitions API — 跨页面或跨路由过渡
// 给共享元素设置匹配的view-transition-name值
// CSS:
// .thumbnail { view-transition-name: hero-image; }
// .full-image { view-transition-name: hero-image; }

document.startViewTransition(() => {
  updateDOM(); // 切换视图
});
设计规则: 识别跨视图保留的元素,给它们添加连续过渡效果,其他所有元素可以作为整体淡入或滑动。这些持续存在的元素会锚定用户的空间感知。
工具: View Transitions API(原生)、Framer Motion(
layoutId
,
AnimatePresence
)、FLIP技术(手动实现)、MPA过渡用Navigation API。

Principle 6: Respond to Input Method, Not Just Screen Size

原则6:适配输入方式而非仅适配屏幕尺寸

Fluid interfaces adapt not only to viewport dimensions but to how the user is interacting. A hover-driven tooltip system should transform into a long-press system on touch. Scroll-linked animations should respect scroll velocity. Pointer precision should influence target sizes. The interface should feel native to whatever input method is currently active.
Why it matters: An interface that shows hover tooltips on a touch device isn't responsive — it's broken. True input responsiveness means the interaction model shifts based on the active input device, not just the screen width.
Implementation:
Use
@media (hover: hover)
and
@media (pointer: fine | coarse)
to adapt interaction patterns. Track active input type dynamically for hybrid devices.
css
/* Hover-dependent interactions only when hover is available */
@media (hover: hover) and (pointer: fine) {
  .tooltip-trigger:hover .tooltip { opacity: 1; }
  .card:hover { transform: translateY(-2px); }
}

/* Larger targets for coarse pointers */
@media (pointer: coarse) {
  .interactive { min-height: 48px; min-width: 48px; }
  .list-item { padding-block: 14px; }
}
javascript
// Scroll velocity detection for scroll-linked animations
let lastScroll = 0, scrollVelocity = 0;

window.addEventListener('scroll', () => {
  scrollVelocity = window.scrollY - lastScroll;
  lastScroll = window.scrollY;

  // Faster scroll = more dramatic parallax / header collapse
  header.style.transform = `translateY(${Math.min(0, -scrollVelocity * 0.5)}px)`;
}, { passive: true });
Tools: CSS Media Queries Level 4 (
hover
,
pointer
,
any-hover
,
any-pointer
),
@use-gesture/react
for normalised cross-input gesture handling, CSS Scroll-Driven Animations for declarative scroll-linked effects.

流畅的界面不仅要适配视口尺寸,还要适应用户的交互方式。悬停触发的提示框系统在触摸设备上应该变为长按触发,滚动关联动画应该考虑滚动速度,指针精度会影响目标元素的大小,界面应该适配当前激活的输入方式,感觉像原生体验一样。
为什么重要: 在触摸设备上展示悬停提示框的界面不算响应式,它是有缺陷的。真正的输入响应性意味着交互模型会根据当前激活的输入设备变化,而不仅仅是根据屏幕宽度变化。
实现方式:
使用
@media (hover: hover)
@media (pointer: fine | coarse)
来适配交互模式,混合设备可以动态追踪当前激活的输入类型。
css
/* 仅在支持悬停时展示悬停相关交互 */
@media (hover: hover) and (pointer: fine) {
  .tooltip-trigger:hover .tooltip { opacity: 1; }
  .card:hover { transform: translateY(-2px); }
}

/* 粗指针设备使用更大的点击目标 */
@media (pointer: coarse) {
  .interactive { min-height: 48px; min-width: 48px; }
  .list-item { padding-block: 14px; }
}
javascript
// 滚动关联动画的滚动速度检测
let lastScroll = 0, scrollVelocity = 0;

window.addEventListener('scroll', () => {
  scrollVelocity = window.scrollY - lastScroll;
  lastScroll = window.scrollY;

  // 滚动越快,视差效果/头部折叠越明显
  header.style.transform = `translateY(${Math.min(0, -scrollVelocity * 0.5)}px)`;
}, { passive: true });
工具: CSS Media Queries Level 4(
hover
,
pointer
,
any-hover
,
any-pointer
)、跨输入手势归一化处理用
@use-gesture/react
、声明式滚动关联效果用CSS Scroll-Driven Animations。

Principle 7: Animate Layout Changes, Don't Teleport

原则7:布局变化使用动效,不要生硬跳转

When elements are added, removed, or reordered in a layout, the surrounding elements should animate to their new positions rather than teleporting. A deleted list item should collapse smoothly while siblings slide up to fill the gap. A new element should expand into existence while pushing its neighbours aside.
Why it matters: Layout jumps are one of the most common sources of visual jank. They break the user's spatial model of the page — elements they were looking at suddenly move without explanation. Animated layout changes maintain continuity and help users track what changed.
Implementation:
Use Framer Motion's
layout
prop for automatic layout animation. For vanilla approaches, use the FLIP technique: record element positions before the DOM change, apply the change, then animate from old positions to new.
jsx
// Framer Motion — automatic layout animation
<AnimatePresence>
  {items.map(item => (
    <motion.li
      key={item.id}
      layout                          // Animate position changes
      initial={{ opacity: 0, y: 20 }} // Enter animation
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, x: -100 }}  // Exit animation
      transition={{ type: "spring", stiffness: 300, damping: 30 }}
    >
      {item.label}
    </motion.li>
  ))}
</AnimatePresence>
javascript
// FLIP technique — vanilla JS
function animateLayoutChange(elements, domUpdate) {
  // FIRST: record current positions
  const positions = elements.map(el => el.getBoundingClientRect());

  // Apply DOM change
  domUpdate();

  // LAST: record new positions
  elements.forEach((el, i) => {
    const newPos = el.getBoundingClientRect();
    const dx = positions[i].left - newPos.left;
    const dy = positions[i].top - newPos.top;

    // INVERT: offset to old position
    el.style.transform = `translate(${dx}px, ${dy}px)`;

    // PLAY: animate to new position
    requestAnimationFrame(() => {
      el.style.transition = 'transform 300ms ease-out';
      el.style.transform = '';
    });
  });
}
Tools: Framer Motion (
layout
,
AnimatePresence
), AutoAnimate (drop-in, zero-config), FLIP technique (manual),
View Transitions API
for document-level layout shifts.

当布局中的元素被添加、移除或重排序时,周围的元素应该动画过渡到新位置,而不是生硬跳转。被删除的列表项应该平滑收起,相邻元素平滑上移填补空缺;新元素应该平滑扩展出现,推开周围的元素。
为什么重要: 布局跳动是最常见的视觉卡顿来源之一,它会破坏用户对页面的空间认知——用户正在看的元素突然毫无理由地移动了。布局变化动效可以保持连续性,帮助用户感知到哪里发生了变化。
实现方式:
使用Framer Motion的
layout
属性实现自动布局动画。原生实现可以用FLIP技术:DOM变化前记录元素位置,应用变化后,从旧位置动画过渡到新位置。
jsx
// Framer Motion — 自动布局动画
<AnimatePresence>
  {items.map(item => (
    <motion.li
      key={item.id}
      layout                          // 位置变化时自动动画
      initial={{ opacity: 0, y: 20 }} // 入场动画
      animate={{ opacity: 1, y: 0 }}
      exit={{ opacity: 0, x: -100 }}  // 退场动画
      transition={{ type: "spring", stiffness: 300, damping: 30 }}
    >
      {item.label}
    </motion.li>
  ))}
</AnimatePresence>
javascript
// FLIP技术 — Vanilla JS实现
function animateLayoutChange(elements, domUpdate) {
  // FIRST: 记录当前位置
  const positions = elements.map(el => el.getBoundingClientRect());

  // 应用DOM变化
  domUpdate();

  // LAST: 记录新位置
  elements.forEach((el, i) => {
    const newPos = el.getBoundingClientRect();
    const dx = positions[i].left - newPos.left;
    const dy = positions[i].top - newPos.top;

    // INVERT: 偏移到旧位置
    el.style.transform = `translate(${dx}px, ${dy}px)`;

    // PLAY: 动画过渡到新位置
    requestAnimationFrame(() => {
      el.style.transition = 'transform 300ms ease-out';
      el.style.transform = '';
    });
  });
}
工具: Framer Motion(
layout
,
AnimatePresence
)、AutoAnimate(开箱即用,零配置)、FLIP技术(手动实现)、文档级布局变化用
View Transitions API

Principle 8: Apply Progressive Resistance at Boundaries

原则8:边界处使用渐进式阻力

When a user drags or scrolls past the boundary of a container, the interface should resist progressively — not stop dead or scroll freely into empty space. This is the rubber-band effect. Pull a little and the element follows at a reduced rate. Pull further and the resistance increases. Release and it snaps back with a spring. This communicates the boundary without blocking the gesture.
Why it matters: Hard stops feel like hitting a wall. Unrestricted overflow feels broken. Progressive resistance communicates "you've reached the edge" in a way that feels physical and informative. It's the difference between an interface that has edges and one that has boundaries.
Implementation:
Apply a logarithmic or square-root dampening function to the overscroll distance. The further past the boundary, the less the element moves per pixel of pointer travel.
jsx
// Framer Motion — elastic drag constraints
<motion.div
  drag="y"
  dragConstraints={{ top: -300, bottom: 0 }}
  dragElastic={0.3}  // 30% tracking beyond boundaries
/>
javascript
// Vanilla — rubber band formula
function rubberBand(offset, limit, elasticity = 0.55) {
  // Returns dampened offset that approaches limit asymptotically
  const clamped = Math.abs(offset);
  return Math.sign(offset) * (limit * (1 - Math.exp(-clamped / limit / elasticity)));
}

// Usage during drag past boundary
const overscroll = currentY - boundaryY;
const dampened = rubberBand(overscroll, 120);
element.style.transform = `translateY(${boundaryY + dampened}px)`;
Where to apply: Scroll containers at their limits, draggable elements at their constraints, pull-to-refresh interactions, bottom sheets at minimum/maximum height, carousel edges.
Tools: Framer Motion (
dragElastic
,
dragConstraints
), CSS
overscroll-behavior
for basic scroll containment, custom rubber-band functions for fine control.

当用户拖拽或滚动超出容器边界时,界面应该渐进式增加阻力,而不是突然停下或者自由滚动到空白区域,这就是橡皮筋效果。轻轻拉的时候元素会以较低的比例跟随,拉得越远阻力越大,释放后会用弹簧效果回弹归位。这种方式可以在不阻断手势的前提下告知用户已经到达边界。
为什么重要: 硬停止的感觉就像撞到了墙,无限制的溢出会感觉界面有问题。渐进式阻力用一种物理化、信息明确的方式传达「你已经到达边缘」,这就是「有边界」和「有死边」的界面的区别。
实现方式:
对超出滚动的距离应用对数或平方根阻尼函数,超出边界越远,指针每移动一像素,元素的移动距离就越小。
jsx
// Framer Motion — 弹性拖拽约束
<motion.div
  drag="y"
  dragConstraints={{ top: -300, bottom: 0 }}
  dragElastic={0.3}  // 超出边界后保留30%的跟随比例
/>
javascript
// 原生实现 — 橡皮筋公式
function rubberBand(offset, limit, elasticity = 0.55) {
  // 返回阻尼后的偏移量,会渐近式接近上限
  const clamped = Math.abs(offset);
  return Math.sign(offset) * (limit * (1 - Math.exp(-clamped / limit / elasticity)));
}

// 拖拽超出边界时使用
const overscroll = currentY - boundaryY;
const dampened = rubberBand(overscroll, 120);
element.style.transform = `translateY(${boundaryY + dampened}px)`;
适用场景: 滚动容器的边界、可拖拽元素的约束边界、下拉刷新交互、底部弹窗的最小/最大高度、轮播图边缘。
工具: Framer Motion(
dragElastic
,
dragConstraints
)、基础滚动遏制用CSS
overscroll-behavior
、精细控制用自定义橡皮筋函数。

Principle 9: Choreograph Sequences, Don't Reveal Everything at Once

原则9:序列动画编排,不要同时展示所有内容

When multiple elements enter or transition together, they should arrive in a staggered sequence — not all at once. A list of cards should cascade in from top to bottom. A dashboard should build up section by section. Simultaneous animation of many elements reads as a single blob; staggered animation gives each element a moment of individual attention and creates a sense of intentional orchestration.
Why it matters: Stagger creates rhythm, directs attention, and makes the interface feel crafted rather than dumped onto the screen. It also improves perceived performance — the first element appears sooner than if you waited for all data before animating everything together.
Implementation:
Apply an incremental delay to each element in a group. Keep individual animation durations consistent; only the start time varies. Total stagger sequences should complete within 400–600ms to avoid feeling slow.
jsx
// Framer Motion — stagger children
const container = {
  animate: {
    transition: {
      staggerChildren: 0.06,      // 60ms between each child
      delayChildren: 0.1,         // 100ms before first child
    }
  }
};

const child = {
  initial: { opacity: 0, y: 12 },
  animate: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } },
};

<motion.ul variants={container} initial="initial" animate="animate">
  {items.map(item => (
    <motion.li key={item.id} variants={child}>{item.label}</motion.li>
  ))}
</motion.ul>
css
/* CSS stagger using custom property */
.stagger-item {
  opacity: 0;
  transform: translateY(12px);
  animation: fadeUp 350ms ease-out forwards;
  animation-delay: calc(var(--i) * 60ms);
}

@keyframes fadeUp {
  to { opacity: 1; transform: translateY(0); }
}
Timing guidelines: Stagger delay of 40–80ms per element. Cap total sequence at ~600ms (so for 10 items at 60ms stagger, the last item starts at 540ms). For very long lists, only stagger the first 8–10 visible items and instant-render the rest.
Tools: Framer Motion (
staggerChildren
,
variants
), GSAP (
stagger
), CSS
animation-delay
with custom properties, AutoAnimate.

当多个元素同时入场或过渡时,应该按错开的序列依次出现,而不是同时出现。卡片列表应该从上到下依次 cascade 入场,仪表盘应该按区块依次加载。多个元素同时动画会看起来像一个整体,错开的动画会让每个元素都获得单独的注意力,营造出精心编排的感觉。
为什么重要: 错开的时序会创造节奏感,引导注意力,让界面感觉是精心设计的,而不是一股脑扔到屏幕上的。它还能提升感知性能:第一个元素出现的时间比等所有数据加载完再一起动画要早得多。
实现方式:
给组内的每个元素应用递增的延迟,保持单个动画的时长一致,仅修改「开始时间」。整个错开序列的总时长应该控制在400-600ms以内,避免感觉缓慢。
jsx
// Framer Motion — 子元素错开动画
const container = {
  animate: {
    transition: {
      staggerChildren: 0.06,      // 每个子元素间隔60ms
      delayChildren: 0.1,         // 第一个子元素延迟100ms开始
    }
  }
};

const child = {
  initial: { opacity: 0, y: 12 },
  animate: { opacity: 1, y: 0, transition: { type: "spring", stiffness: 300, damping: 24 } },
};

<motion.ul variants={container} initial="initial" animate="animate">
  {items.map(item => (
    <motion.li key={item.id} variants={child}>{item.label}</motion.li>
  ))}
</motion.ul>
css
/* 使用自定义属性实现CSS错开动画 */
.stagger-item {
  opacity: 0;
  transform: translateY(12px);
  animation: fadeUp 350ms ease-out forwards;
  animation-delay: calc(var(--i) * 60ms);
}

@keyframes fadeUp {
  to { opacity: 1; transform: translateY(0); }
}
时间指南: 每个元素的错开延迟为40-80ms,总序列时长上限约600ms(所以10个元素每个错开60ms的话,最后一个元素会在540ms开始)。对于非常长的列表,仅给前8-10个可见元素应用错开动画,剩下的直接渲染即可。
工具: Framer Motion(
staggerChildren
,
variants
)、GSAP(
stagger
)、自定义属性+CSS
animation-delay
、AutoAnimate。

Principle 10: Respect the User's Motion Preferences

原则10:尊重用户的动效偏好

All of the above principles are subordinate to this one: if the user has indicated they prefer reduced motion, honour that preference completely. This means replacing spring animations with instant or near-instant transitions, disabling parallax and auto-playing animations, and simplifying gesture interactions. Fluid doesn't mean motion-heavy — for users who experience motion sensitivity, a fluid interface is one that transitions cleanly without triggering discomfort.
Why it matters: Motion sensitivity affects a significant portion of users. Vestibular disorders, migraines, and other conditions can make animated interfaces physically uncomfortable. An interface that ignores
prefers-reduced-motion
isn't fluid — it's hostile.
Implementation:
Provide a reduced-motion mode that preserves spatial relationships and feedback (opacity, colour changes) while eliminating translation, scale, and rotation animations.
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;
  }
}
jsx
// Framer Motion — respect motion preference
import { useReducedMotion } from "framer-motion";

function Card({ children }) {
  const reducedMotion = useReducedMotion();

  return (
    <motion.div
      layout={!reducedMotion}
      initial={reducedMotion ? false : { opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={reducedMotion
        ? { duration: 0 }
        : { type: "spring", stiffness: 300, damping: 24 }
      }
    >
      {children}
    </motion.div>
  );
}
Rule: Never disable
prefers-reduced-motion
behaviour for aesthetic reasons. Always provide a reduced-motion path. Test your interface with the preference enabled. The interface should still feel complete and usable — just quieter.
Tools:
prefers-reduced-motion
media query, Framer Motion's
useReducedMotion()
,
matchMedia('(prefers-reduced-motion: reduce)')
in JS.

以上所有原则都要服从本条:如果用户明确表示偏好减少动效,要完全遵守该偏好。这意味着将弹簧动画替换为即时或接近即时的过渡,禁用视差和自动播放动画,简化手势交互。流畅不等于动效多——对于有动效敏感度的用户来说,流畅的界面是指可以干净过渡、不会引发不适的界面。
为什么重要: 动效敏感度影响着很大一部分用户,前庭障碍、偏头痛和其他疾病会让动画界面带来身体上的不适。忽略
prefers-reduced-motion
的界面不是流畅的,是不友好的。
实现方式:
提供减少动效模式,保留空间关系和反馈(透明度、颜色变化),同时移除位移、缩放和旋转动画。
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;
  }
}
jsx
// Framer Motion — 尊重动效偏好
import { useReducedMotion } from "framer-motion";

function Card({ children }) {
  const reducedMotion = useReducedMotion();

  return (
    <motion.div
      layout={!reducedMotion}
      initial={reducedMotion ? false : { opacity: 0, y: 12 }}
      animate={{ opacity: 1, y: 0 }}
      transition={reducedMotion
        ? { duration: 0 }
        : { type: "spring", stiffness: 300, damping: 24 }
      }
    >
      {children}
    </motion.div>
  );
}
规则: 永远不要为了美观禁用
prefers-reduced-motion
行为,始终提供减少动效的路径。开启该偏好测试你的界面,界面仍然应该是完整可用的,只是更安静而已。
工具:
prefers-reduced-motion
媒体查询、Framer Motion的
useReducedMotion()
、JS中的
matchMedia('(prefers-reduced-motion: reduce)')

Quick Reference

快速参考

PrincipleCore IdeaKey Tool
1. Physics-Based MotionSprings, not durationsFramer Motion, React Spring
2. Every Animation InterruptibleCancel and redirect mid-flightSprings (inherently interruptible)
3. Direct ManipulationDrag, swipe, pinch over buttons
@use-gesture/react
, Framer
drag
4. Preserve VelocityGesture momentum carries into animation
initialVelocity
on springs
5. Shared Element TransitionsElements persist across views
layoutId
, View Transitions API
6. Respond to Input MethodAdapt to touch, mouse, scroll velocity
hover
,
pointer
media queries
7. Animate Layout ChangesDon't teleport when layout shifts
layout
prop, FLIP, AutoAnimate
8. Progressive ResistanceRubber-band at boundaries
dragElastic
, rubber-band math
9. Choreograph SequencesStagger, don't reveal all at once
staggerChildren
, GSAP stagger
10. Respect Motion PreferencesReduced motion is not no motion
prefers-reduced-motion
原则核心理念核心工具
1. 基于物理的动效用弹簧而非固定时长Framer Motion, React Spring
2. 所有动画均可中断动画执行过程中可随时取消、重定向弹簧动画(天然支持可中断)
3. 优先直接操纵优先拖拽、滑动、捏合而非点击按钮
@use-gesture/react
, Framer
drag
4. 保留手势速度手势动量延续到后续动画中弹簧动画的
initialVelocity
参数
5. 共享元素转场元素在不同视图间保持连续性
layoutId
, View Transitions API
6. 适配输入方式适配触摸、鼠标、滚动速度等不同输入方式
hover
,
pointer
媒体查询
7. 布局变化使用动效布局变化时不要生硬跳转
layout
属性, FLIP, AutoAnimate
8. 边界处渐进式阻力边界处使用橡皮筋效果
dragElastic
, 橡皮筋算法
9. 序列动画编排错开动效时机,不要同时展示所有内容
staggerChildren
, GSAP stagger
10. 尊重用户动效偏好减少动效不等于没有动效
prefers-reduced-motion