fluid-interactions

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Fluid interactions

流畅交互

Interfaces where motion behaves like physical objects instead of playing back a timeline. That covers:
  • Gestures: things follow the finger, carry its speed when released, land where a flick was heading, resist at their edges, and can be caught mid-animation.
  • Transitions: sheets, navigation, and views that grow out of the thing you tapped and can be dragged back.
  • Gesture modes: one gesture doing different things depending on how it moves.
  • Getting it right everywhere: touch quirks, performance, and reduced motion.
运动表现如同物理对象,而非播放预制时间线的界面。涵盖:
  • 手势: 元素跟随手指,释放时保留手指速度,落在甩动原本指向的位置,在边缘产生阻力,并且可以在动画中途重新抓住。
  • 转场: 面板、导航和视图从用户点击的对象处扩展出来,并且可以反向拖回。
  • 手势模式: 同一个手势根据移动方式执行不同操作。
  • 处处可靠: 处理触摸设备差异、性能问题和 reduced motion(减少动态效果)。

Use motion

使用 motion

Always build these interactions with motion. Its springs start from a velocity, its motion values update without re-rendering, and it follows reduced motion, which is everything this skill relies on. Don't use GSAP, anime.js or CSS transitions for them; the code here and in the references assumes motion. (The one hand-written loop, coasting in a custom scroller, still writes to a motion value and hands off to a motion spring.)
Install it before writing any code if it isn't in
package.json
, using the project's package manager (check the lockfile):
sh
npm install motion     # package-lock.json
pnpm add motion        # pnpm-lock.yaml
yarn add motion        # yarn.lock
bun add motion         # bun.lock / bun.lockb
  • React: import from
    motion/react
    (
    motion
    ,
    animate
    ,
    useMotionValue
    ,
    useTransform
    ,
    MotionConfig
    ,
    useReducedMotion
    ).
  • Already has
    framer-motion
    :
    that's motion under its old name. Use it as it is, importing from
    framer-motion
    ; don't install both.
  • Already uses another animation library: keep it for whatever it already does, and add motion alongside it for these interactions. Don't rebuild these patterns in the other library.
Never fake a spring with a duration and an easing curve (
elastic
,
back
, cubic-bezier overshoot). They can't start from the finger's speed.
始终使用 motion 构建这些交互。它的弹簧可以从速度起步,motion value 无需重新渲染即可更新,并且会遵循 reduced motion;这些正是此 skill 所依赖的全部能力。不要使用 GSAP、anime.js 或 CSS transitions 实现这些交互;这里的代码和 references 均以 motion 为基础。(唯一一个手写循环,即自定义 scroller 中的惯性滑行,仍会写入 motion value,并移交给 motion spring。)
如果
package.json
中还没有 motion,请在编写任何代码前使用项目的包管理器进行安装(检查 lockfile):
sh
npm install motion     # package-lock.json
pnpm add motion        # pnpm-lock.yaml
yarn add motion        # yarn.lock
bun add motion         # bun.lock / bun.lockb
  • React: 从
    motion/react
    导入(
    motion
    、
    animate
    、
    useMotionValue
    、
    useTransform
    、
    MotionConfig
    、
    useReducedMotion
    )。
  • 已经有
    framer-motion
    :
    它就是 motion 的旧名称。直接使用,并从
    framer-motion
    导入;不要同时安装两者。
  • 已经使用其他动画库: 保留该库及其现有用途,同时为这些交互添加 motion。不要使用其他库重新实现这些模式。
永远不要用持续时间和缓动曲线来模拟 spring(
elastic
、
back
、cubic-bezier overshoot)。它们无法承接手指速度。

The rules

规则

  1. Animate with springs, not durations. A spring only needs a target and a starting velocity, so it can pick up from wherever the finger left off. Describe springs by response (seconds) and damping ratio with
    springFromResponse
    , not raw stiffness and damping. See springs.
  2. Hand the release velocity to the spring. Track the pointer with
    VelocityTracker
    (last 100ms only) and pass
    velocity
    into the animation. Without it, the element stops dead for a frame before setting off.
  3. Everything is interruptible. On pointer down,
    stop()
    the animation and read the current value as the new origin. Never block input while something is animating. Decide state (open or closed, which page is current) from where an animation is heading, not from the value or
    onComplete
    : a spring looks done long before it finishes. See drag and momentum and decide from the target.
  4. Decide where it lands from where it was going. On release,
    project(velocity)
    gives where a flick would coast to; snap to the nearest target from that point, not from the release point. That is what lets a short, fast flick skip a detent.
  5. Rubber band past the edges instead of hard-stopping:
    applyRubberBand(value, min, max, d)
    . The further you pull, the less it moves.
  6. Directions that lead nowhere resist. If only one direction does something (down dismisses, right goes back), the others still move, but under a rubber band, then spring back on release. Don't freeze them and don't let them move freely: free movement suggests an action that isn't there. This is only for something the user is holding that moves freely. If the other axis belongs to something else (page scroll, a parent list), hand it off with a direction lock instead of rubber banding. See drag and momentum.
  7. Lock the direction before acting. Wait about 10px, then commit to an axis. Only capture the pointer after the lock, so taps and the other axis (usually native scroll) still work. See direction lock.
  8. Per-frame work never touches React state. Use refs and motion values (or direct DOM writes). Only set state on release or on a discrete change.
  9. Move with transforms, not layout.
    x
    ,
    y
    ,
    scale
    ,
    opacity
    . Read layout (
    getBoundingClientRect
    ,
    clientWidth
    ) once at the start or end of a gesture, never every frame.
  10. Always end the gesture. Handle
    pointerup
    ,
    pointercancel
    and
    lostpointercapture
    . A gesture that misses its end leaves the UI stuck. See pointer events.
  11. Respect reduced motion by softening what moves by itself, never what the finger drives. See reduced motion.
  12. One gesture can mean several things. How it moves (where it started, distance, speed, a pause, a second axis) can pick between related outcomes, like swiping up to go home or pausing to open the app switcher, or moving away from a scrubber to scrub more finely. The plain version must always work, and the mode must show before release. See gesture modes.
  1. 使用 spring 动画,而不是固定持续时间。 spring 只需要目标值和初始速度,因此能够从手指离开的位置继续运动。使用
    springFromResponse
    通过 response(秒)和 damping ratio(阻尼比)描述 spring,而不是直接设置 stiffness 和 damping。参见 springs。
  2. 将释放速度传递给 spring。 使用
    VelocityTracker
    跟踪指针(仅使用最近 100ms 的数据),并将
    velocity
    传入动画。否则,元素会先完全停顿一帧,然后才开始运动。
  3. 所有动画都可以中断。 在 pointer down 时调用
    stop()
    停止动画,并将当前值作为新的起点。动画进行时绝不能阻止输入。根据动画的目标方向决定状态(打开或关闭、当前是哪一页),而不是根据当前值或
    onComplete
    决定:spring 在真正结束前很久,看起来就已经完成。参见 drag and momentum 和 decide from the target。
  4. 根据运动方向决定最终落点。 释放时,
    project(velocity)
    会给出甩动惯性滑行后的落点;从该位置吸附到最近的 target,而不是从释放位置吸附。这能让短促而快速的甩动越过中间吸附档位。
  5. 越过边缘时使用橡皮筋效果,而不是突然停止:
    applyRubberBand(value, min, max, d)
    。拉动距离越大,移动距离越小。
  6. 没有实际作用的方向应产生阻力。 如果只有一个方向会产生作用(向下关闭、向右返回),其他方向仍然可以移动,但应采用橡皮筋效果,并在释放后通过 spring 回弹。不要将其冻结,也不要任其自由移动;自由移动会暗示存在对应操作。此规则仅适用于由用户按住并可自由移动的对象。如果另一个轴向属于其他元素(页面滚动、父级列表),应使用方向锁定将其交由对方处理,而不是使用橡皮筋效果。参见 drag and momentum。
  7. 执行操作前先锁定方向。 等待约 10px,然后确定一个轴向。只有完成锁定后才能捕获指针,这样点击和另一个轴向(通常是原生滚动)仍然可用。参见 direction lock。
  8. 逐帧处理绝不能修改 React state。 使用 refs 和 motion values(或直接写入 DOM)。只在释放时或发生离散变化时设置 state。
  9. 使用 transforms 移动,而不是修改 layout。 使用
    x
    、
    y
    、
    scale
    、
    opacity
    。仅在手势开始或结束时读取布局信息(
    getBoundingClientRect
    、
    clientWidth
    ),绝不要每帧读取。
  10. 手势必须始终结束。 处理
    pointerup
    、
    pointercancel
    和
    lostpointercapture
    。遗漏结束事件会让 UI 卡住。参见 pointer events。
  11. 尊重 reduced motion。 减弱自动发生的运动,但不要减弱由手指直接驱动的运动。参见 reduced motion。
  12. 一个手势可以表达多种含义。 移动方式(起始位置、距离、速度、停顿、第二个轴向)可以在相关结果之间做出选择,例如向上滑动返回主页、停顿后打开应用切换器,或从 scrubber 上移开以更精细地调节。基础版本必须始终可用,并且必须在释放前显示当前模式。参见 gesture modes。

Code

代码

Copy these into the project and adapt them.
physics.ts
has no dependencies; the hooks need React, and
use-motion-preference.ts
needs motion.
  • code/physics.ts:
    springFromResponse
    ,
    noBounce
    ,
    VelocityTracker
    ,
    rubberBand
    ,
    applyRubberBand
    ,
    rubberBandInverse
    ,
    project
    ,
    DECELERATION
    ,
    nearest
    ,
    nearestPoint
    ,
    clamp
    ,
    FADE
    .
  • code/use-drag.ts: a pointer-events drag hook that reports the offset from the start and the release velocity, and always calls
    onEnd
    .
  • code/use-motion-preference.ts:
    { reduceMotion, spring }
    , where
    spring()
    drops the bounce when reduced motion is on.
将这些代码复制到项目中并按需调整。
physics.ts
不依赖任何包;hooks 需要 React,而
use-motion-preference.ts
需要 motion。
  • code/physics.ts:
    springFromResponse
    、
    noBounce
    、
    VelocityTracker
    、
    rubberBand
    、
    applyRubberBand
    、
    rubberBandInverse
    、
    project
    、
    DECELERATION
    、
    nearest
    、
    nearestPoint
    、
    clamp
    、
    FADE
    。
  • code/use-drag.ts:一个基于 pointer events 的拖拽 hook,用于报告相对起点的偏移量和释放速度,并且始终调用
    onEnd
    。
  • code/use-motion-preference.ts:提供
    { reduceMotion, spring }
    ;启用 reduced motion 时,
    spring()
    会去除回弹。

The basic shape of every gesture

每种手势的基本结构

tsx
const x = useMotionValue(0);
const origin = useRef(0);

const drag = useDrag({
  onStart: () => {
    x.stop(); // catch it mid-flight
    origin.current = x.get(); // continue from where it is on screen
  },
  onMove: (delta) => {
    x.set(applyRubberBand(origin.current + delta.x, min, max, width));
  },
  onEnd: (velocity) => {
    const landing = x.get() + project(velocity.x);
    animate(x, nearest(landing, targets), {
      ...spring(SNAP),
      velocity: velocity.x,
    });
  },
});

return (
  <motion.div {...drag} style={{ x }} className="touch-none select-none" />
);
tsx
const x = useMotionValue(0);
const origin = useRef(0);

const drag = useDrag({
  onStart: () => {
    x.stop(); // catch it mid-flight
    origin.current = x.get(); // continue from where it is on screen
  },
  onMove: (delta) => {
    x.set(applyRubberBand(origin.current + delta.x, min, max, width));
  },
  onEnd: (velocity) => {
    const landing = x.get() + project(velocity.x);
    animate(x, nearest(landing, targets), {
      ...spring(SNAP),
      velocity: velocity.x,
    });
  },
});

return (
  <motion.div {...drag} style={{ x }} className="touch-none select-none" />
);

Patterns

模式

Read the reference for the pattern you are building:
BuildingRead
Any spring, picking response and dampingsprings
Draggable or flickable element, snapping to corners or points, custom scroller with momentum and pagingdrag and momentum
Horizontal swipe inside a vertical list, swipe actions, pull to refreshdirection lock
Bottom sheet with detentssheets
Video or audio scrubber with fine scrubbingscrubbing
Thumbnail that grows into a detail view and drags back down to closemorphing
Edge swipe to go back in a navigation stackswipe back
One gesture with several outcomes or modes (home bar, fine scrubbing, stretching swipe actions)gesture modes
Reduced motionreduced motion
A gesture that freezes, gets stuck, or breaks taps on touchpointer events
构建相应模式时,请阅读对应的参考文档:
要构建的功能参考文档
任何 spring,以及 response 和 damping 的选择springs
可拖拽或甩动的元素、吸附到角点或指定位置、带惯性和分页的自定义 scrollerdrag and momentum
垂直列表中的水平滑动、滑动操作、下拉刷新direction lock
带吸附档位的底部面板sheets
支持精细调节的视频或音频 scrubberscrubbing
缩略图扩展为详情视图,并可向下拖动关闭morphing
在导航栈中从边缘侧滑返回swipe back
一个手势对应多种结果或模式(home bar、精细拖动、拉伸式滑动操作)gesture modes
Reduced motionreduced motion
在触摸设备上冻结、卡住或破坏点击的手势pointer events

Defaults that feel right

体验良好的默认值

ThingValue
Most UI springs
springFromResponse(0.35–0.45, 0.85–0.9)
Navigation, paging, anything that shouldn't overshootdamping ratio
1
Velocity windowlast 100ms
Direction lock threshold10px
Lock ratio for a strict axisthe winning axis is 2× the other
Rubber band constant
c = 0.55
,
d
= the container's size on that axis
Projection deceleration
0.998
(normal),
0.99
(fast, stops sooner)
Edge-swipe zone24px from the edge
Reduced-motion fade200ms ease-out
Two choices have their own guidance, so don't guess them: how much a spring should bounce (springs) and whether a scroller coasts normally, stops fast or pages (drag and momentum). When unsure, no bounce and normal deceleration.
对象值
大多数 UI spring
springFromResponse(0.35–0.45, 0.85–0.9)
导航、分页,以及任何不应产生 overshoot 的效果damping ratio 为
1
速度采样窗口最近 100ms
方向锁定阈值10px
严格轴向的锁定比例胜出的轴向是另一个轴向的 2×
橡皮筋常数
c = 0.55
,
d
为容器在该轴向上的尺寸
投射减速度
0.998
(正常)、
0.99
(快速,更早停止)
边缘滑动区域距边缘 24px
Reduced-motion 淡出200ms ease-out
有两项选择各有专门的指南,不要凭空猜测:spring 应产生多大回弹,以及 scroller(滚动容器)应正常惯性滑行、快速停止还是分页。不确定时,使用无回弹和正常减速度。

Checklist before calling it done

完成前的检查清单

  • Grab it mid-animation: it stops exactly where it is, no jump.
  • Act again the moment an animation looks finished (open another row, tap the revealed page, swipe the other way): it responds straight away, no dead quarter-second.
  • Flick it: it leaves at the finger's speed, no hitch on release.
  • Drag past the edge: it resists and springs back.
  • Drop something that can rest anywhere inside its bounds: it stays exactly there, no wobble.
  • Drag in a direction that does nothing: it resists (not frozen, not free) and springs back.
  • On a phone: drag slowly; it tracks the finger and doesn't scroll the page (
    touch-action
    ).
  • Taps on buttons and rows inside the draggable still work.
  • Put a second finger down mid-drag: nothing breaks.
  • Switch apps or open the context menu mid-drag: it settles, it doesn't stay stuck.
  • Turn on reduced motion: no overshoot, big automatic movements fade, dragging is unchanged.
  • 在动画中途抓住它:它会准确停在当前位置,不会跳动。
  • 当动画看起来已经完成时立即再次操作(打开另一行、点击出现的页面、向另一个方向滑动):它会立即响应,不会出现四分之一秒的无效停顿。
  • 甩动它:它会带着手指的速度离开,释放时没有卡顿。
  • 拖动越过边缘:它会产生阻力并通过 spring 回弹。
  • 将某个元素放在边界内任意可停留的位置:它会准确停在那里,不会晃动。
  • 沿没有实际作用的方向拖动:它会产生阻力(既不冻结,也不自由移动),并通过 spring 回弹。
  • 在手机上缓慢拖动:它会跟随手指,并且不会滚动页面(
    touch-action
    )。
  • 可拖拽元素内部按钮和行上的点击仍然有效。
  • 拖动过程中按下第二根手指:不会发生异常。
  • 拖动过程中切换应用或打开上下文菜单:元素会稳定下来,不会一直卡住。
  • 启用 reduced motion:不会产生 overshoot,大幅自动运动会淡出,拖动行为保持不变。