apple-design · motion labapple-design · 交互手感实验室

Motion you can grab. 抓得住的动效。

Every figure on this page is operable — drag it, flick it, interrupt it mid-flight. The system behind them is two layers with one hard boundary: page motion uses easing curves and never bounces; springs live only inside gesture-driven components. Zero dependencies, from WWDC's fluid-interfaces playbook. 这一页的每张图都能上手——拖它、甩它、飞行途中抓住它。背后是带一条硬边界的两层体系:页面动效只用缓动曲线、永不弹跳;弹簧只住在手势驱动的组件里。零依赖,源自 WWDC 流体界面方法。

Layer 1 · Page motion Easing curves, fixed durations Reveal on scroll 700ms · stagger 80ms Hover / tab states 240ms ease-out Press feedback :active · instant Enter = exit path mirrored easing Springs never enter Layer 2 · Gesture components Springs, velocity, interruption grab settle 1:1 tracking velocity handoff rubber-band One reduced-motion gate — CSS handled globally · every JS spring checks matchMedia before it glides
The whole system in one picture. Layer 1 animates the page with easing tokens and never overshoots. Layer 2 owns everything a finger can grab — and the blue spring path is the only place bounce is allowed to exist. Both layers drain into a single reduced-motion gate. 一张图说完整个体系。第一层用缓动 token 驱动页面,永不过冲;第二层拥有一切手指能抓住的东西——蓝色弹簧路径是弹跳唯一被允许存在的地方。两层最终汇入同一道减动效之门。

Layer 1 — page motion 第一层 — 页面动效

Entrances, hovers, presses. Everything here runs on CSS easing tokens — no springs, no physics, no surprises. The craft is in latency: feedback lands on pointer-down, and panels leave the way they came. 入场、悬停、按压。这一层全部跑在 CSS 缓动 token 上——没有弹簧、没有物理、没有意外。工夫在延迟上:反馈落在按下的瞬间,面板从哪来回哪去。

01 · Press feedback — feel the dead button01 · 按压反馈 — 感受一下"死"按钮

Press and hold each button. The left one responds the instant your finger lands; the right one waits for release plus 220ms — the moment lag appears, directness falls off a cliff. 按住两个按钮各试一次。左边在手指落下的瞬间就有反应;右边等你松手再等 220ms——延迟一出现,"直接操纵"的感觉立刻消失。

Feedback on pointer-down. 100ms scale, nothing waits.反馈在 pointer-down。100ms 缩放,不等任何事。

Feedback only after release, artificially delayed. This is what debounces and tap delays feel like.反馈只在松手后,还被人为延迟。防抖和 tap delay 就是这种手感。

.apple-button:active {
  transform: scale(0.97);        /* lands the moment the pointer does */
  transition: transform 100ms var(--ease-apple-out);
}
Rule: feedback belongs to pointer-down. Anything on the input path that is not essential — debounce, tap delay, transition wait — is a regression. 规则:反馈属于 pointer-down。输入路径上一切非必要的东西——防抖、tap delay、过渡等待——都是退步。

02 · Reveal on scroll — easing, stagger, no bounce02 · 滚动浮现 — 缓动 + 错峰,不弹

The standard apple-design entrance: 700ms ease-out, 80ms sibling stagger, opacity plus 24px of travel. Replay it — notice it settles without overshoot. 标准的 apple-design 入场:700ms ease-out、兄弟元素错峰 80ms、透明度 + 24px 位移。重放一次——注意它落定时没有过冲。

Tokens

One easing pair, three durations.一对缓动,三档时长。

Stagger

80ms between siblings, ≤5 per group.兄弟间 80ms,每组 ≤5。

Budget

2–3 animated nodes per page.每页 2–3 个动画节点。

e.target.style.transition =
  `opacity 700ms cubic-bezier(0.25,1,0.5,1) ${i * 80}ms,
   transform 700ms cubic-bezier(0.25,1,0.5,1) ${i * 80}ms`;  /* IntersectionObserver entrance */
Rule: entrances never overshoot. Bounce belongs to gestures with momentum, and a card fading in has none. 规则:入场永不过冲。弹跳属于带动量的手势,而淡入的卡片没有动量。

03 · Spatial consistency — leave the way you came03 · 空间一致性 — 从哪来,回哪去

A panel that slides in from the right must dismiss to the right. Play both versions — the broken one exits downward, and the room stops making sense. 从右侧滑入的面板必须向右退出。两个版本各播一次——坏的那个向下退场,空间关系立刻断裂。

The panel anchors to the right edge — its home. Exit through the floor and it no longer has one.面板锚定在右缘——那是它的家。从地板退场,它就没有家了。

/* in from the right … */  transform: translateX(0);
/* … out to the right */   transform: translateX(calc(100% + 40px));
/* mirror the easing so the return path matches the way in */
Rule: if something disappears one way, it must emerge from where it went. Anchor popovers to their trigger with transform-origin for the same reason. 规则:东西从哪个方向消失,就该从哪里回来。popover 用 transform-origin 锚定触发元素,也是同一个道理。

Layer 2 — gesture feel 第二层 — 手势手感

Everything below tracks your pointer 1:1, hands your velocity to a spring on release, and can be grabbed back mid-flight. This is where fluid comes from. 下面每个例子都 1:1 跟着指针走,松手时把你的速度交给弹簧,飞行途中随时能被抓回来。"流畅"就是从这里来的。

30
lines for the whole spring行代码实现整个弹簧
0
dependencies, plain rAF依赖,纯 rAF
0.998
deceleration rate Apple shipsApple 出货的减速率

04 · Spring playground — interrupt it mid-flight04 · 弹簧试验场 — 飞行途中打断它

Click anywhere in the strip and the ball springs there. Click again before it settles: the new flight starts from the current position and the current velocity — no jump, no brick wall. Then try Apple's shipped parameter pairs. 在条带里任意位置点击,小球弹簧过去。落定前再点一次:新的飞行从当前位置当前速度出发——不跳变、不撞墙。然后试试 Apple 出货的参数组。

const w0 = (2 * Math.PI) / response;               // Apple's two designer params
v += (-w0*w0*(x - target) - 2*damping*w0*v) * dt;  // semi-implicit Euler
x += v * dt;                                       // retarget() = interruption
Rule: always animate from the presentation value. Reading the live position and velocity is what makes interruption seamless — a spring has no fixed duration to wait out. 规则:永远从呈现值起动画。读取实时位置和速度,打断才没有接缝——弹簧没有固定时长,不需要等它播完。

05 · Flick carousel — velocity handoff + momentum projection05 · 甩动轮播 — 速度交接 + 动量投射

Drag the row and let go with a flick. The release velocity projects a landing point — (v/1000)·d/(1−d) — and the track snaps to the card nearest that projection, not nearest your finger. The blue index is the chosen target. 拖动卡片行,然后甩手放开。松手速度投射出滑行落点——(v/1000)·d/(1−d)——轨道吸附到离落点最近的卡片,而不是离手指最近的。蓝色编号就是被选中的目标。

const landing = current + project(releaseVelocity);   // where the flick is going
const target  = nearestSnap(landing);                 // choose from the projection
spring(current, target, releaseVelocity, apply);      // then hand off the velocity
Rule: take a small input and make a big output — project the momentum, choose the target from the projection, then hand the velocity to the spring. 规则:小输入,大输出——先投射动量,从落点选目标,再把速度交给弹簧。

06 · Rubber-band — soft boundaries06 · 橡皮筋 — 柔性边界

Drag the knob past either end of the groove. The thin gray line is your actual pointer; the knob falls progressively behind it — resistance that grows with overshoot. Release and it springs home with your velocity. 把旋钮拖出凹槽两端。细灰线是你指针的真实位置;旋钮渐渐落在它后面——阻力随越界量增长。松手后带着你的速度弹回边界。

function rubberband(overshoot, dimension, c = 0.55) {
  return (overshoot * dimension * c) / (dimension + c * Math.abs(overshoot));
}   /* a hard stop reads as “frozen”; growing resistance reads as “nothing more here” */
Rule: real things slow before they stop. Resistance that grows with overshoot says “responsive, but there is nothing more here”. 规则:真实的东西先减速再停下。随越界增长的阻力在说:"我有反应,但这里没有更多了"。

07 · Sheet — velocity decides, not position07 · 抽屉 — 速度说了算,不是位置

The classic bottom sheet, with Apple's shipped drawer parameters. Two things to try, described on the right. 经典的底部抽屉,用的是 Apple 抽屉出货参数。右边写了两件值得一试的事。

Drag the sheet. Release past halfway while flicking downward and it still closes — the release decision reads the sign of your velocity, not where the sheet happens to be.拖这张抽屉。拖过一半但往下甩着松手,它照样关闭——松手判定读的是速度的方向,不是抽屉恰好在哪。

Grab it mid-flight. While it glides, catch it again: it follows from its current on-screen position. Apple ships this pair as damping 0.8, response 0.3.飞行途中抓住它。滑行时再抓一次:它从当前屏上位置继续跟手。Apple 给抽屉出货的参数是阻尼 0.8、响应 0.3。

const goOpen = Math.abs(v) > 150 ? v < 0            // fast: the flick direction wins
                                 : y < travel / 2;  // slow: nearest detent
spring(y, goOpen ? OPEN : PEEK, v, apply, { damping: 0.8, response: 0.3 });
Rule: at release, the flick's direction outranks the sheet's position — reading position alone makes fast gestures feel ignored. 规则:松手时,甩的方向优先于抽屉的位置——只看位置,快手势会觉得被无视。

Apple's shipped valuesApple 的出货参数

Interaction交互 dampingresponse Why为什么
Move / reposition移动 / 归位1.00.4no momentum in the gesture → no overshoot手势不带动量 → 不许过冲
Rotation旋转0.80.4spin carries momentum → a little bounce旋转带动量 → 一点回弹
Drawer / sheet抽屉 / sheet0.80.3flicked shut → snappier, slight give被甩上 → 更快落定,略有余韵
The rule behind the table: bounce is earned by momentum. A menu that faded in has none; a card you flicked has plenty. 表格背后的规则:弹跳是动量挣来的。淡入的菜单没有动量;被你甩出去的卡片有的是。

One reduced-motion gate 一道减动效之门

CSS is handled globally by apple.css. But rAF springs are JavaScript — the stylesheet cannot stop them. Every spring call on this page checks the same gate first. Flip the switch: every demo above obeys it, live. CSS 由 apple.css 全局兜底。但 rAF 弹簧是 JavaScript——样式表拦不住它。这一页每次弹簧调用都先过同一道门。拨动开关:上面所有 demo 立刻全部服从。

08 · The gate, demonstrated08 · 这道门,现场演示

Simulate prefers-reduced-motion: reduce模拟 prefers-reduced-motion: reduce
Reduced motion ≠ no feedback. Dragging still tracks 1:1 — that is direct manipulation, not animation. What disappears is the glide: springs and projections collapse to a short cross-fade at the destination. The real OS setting works here too, not just the switch. 减动效 ≠ 没反馈。拖拽仍然 1:1 跟手——那是直接操纵,不是动画。消失的是滑行:弹簧和投射塌缩成落点处一次短暂的淡入。系统级设置在这页同样生效,不只是这个开关。
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
if (reduce) { jumpTo(target); fadeIn(120); }   // no glide, keep the outcome
else        { spring(current, target, velocity, apply); }
Read the full playbook, motion.md读完整规则 motion.md Diagram gallery图示案例库