Motion Physics
Every convincing interface animation is a claim about a physical object: it has mass, it carries momentum, and it cannot occupy two positions at once. The values here are where a simulated object starts reading as a thing rather than an animation — and where motion starts reading as a delay rather than a response.
The spring on iOS, the interpolator on Android, and the spring in a web motion library are three encodings of one second-order system, and a damping ratio produces the same perceived overshoot in all three. Tune in physics, translate to API — the reverse produces three interfaces that disagree.
1. The frequency gate runs first
Before selecting any duration, curve, or spring: how often will this user see this animation?
| Frequency | Motion |
|---|---|
| 100+ a day — shortcuts, command palettes | None |
| Tens a day — navigation, tab switches, lists | Near-imperceptible, or none |
| Occasional — modals, drawers, toasts | The standard values here |
| Rare — first launch, empty states | Expressive motion is affordable |
The mechanism is habituation. A 200ms transition experienced once is a pleasing detail; experienced 200 times in a working day it is 40 seconds of waiting. Charm decays with exposure; cost does not. This gate can legitimately return "build nothing", and does so more often than it is allowed to — the commonest motion defect in generated interfaces is a well-tuned animation on something that should have been instant.
Whatever survives names exactly one purpose: feedback, spatial consistency, state indication, preventing a jarring change, explanation, or delight. One claiming three has identified none; one claiming none is deleted.
2. Springs are damping ratio and response
Damping ratio governs overshoot: 1.0 is critically damped and reaches the target without crossing it, 0.8 gives one barely-conscious overshoot, 0.5 a visible bounce. Response is roughly the time to reach the target. Never parameterise as mass, stiffness and damping — the triple is coupled, so raising stiffness to go faster silently lowers the damping ratio and adds overshoot.
| Motion | Damping | Response |
|---|---|---|
| Reposition / move | 1.0 | 0.4s |
| Rotation | 0.8 | 0.4s |
| Drawer / sheet | 0.8 | 0.3s |
On the web bounce = 1 − dampingRatio and duration plays the role of response, so a drawer is { type: "spring", bounce: 0.2, duration: 0.3 }.
Bounce requires momentum. Overshoot is kinetic energy the object could not shed instantly, so it is legible only when the interaction supplied that energy. A tap is an impulse of zero duration at a point unrelated to the destination, so an overshoot afterwards has no attributable cause and reads as a glitch — a modal that springs 4% past its size is briefly the wrong size. The test: did the user's hand move the way the element is now moving? If not, bounce: 0.
3. Handoff, projection, and boundaries
When a gesture ends and a spring takes over, the spring's initial velocity must equal the release velocity, or the object decelerates to zero at release and accelerates again from rest — a stop-and-restart inside one continuous motion, destroying the illusion the user was touching the object. Measure over the last 50–100ms of pointer samples: the final two events are one frame apart and dominated by sensor noise, so a steady 800px/s finger reports 40px/s or 3000px/s depending which pair you catch. Where a fling lands, and how a boundary resists:
project(v, d) = (v / 1000) * d / (1 - d) // d = 0.998 lists, 0.99 paging
rubberBand(x, dim, c = 0.55) = (x * dim * c) / (dim + c * Math.abs(x))
Snap to the point nearest the projected endpoint, not the release point. Snapping from where the finger lifted discards the energy the user put in, so a hard flick and a gentle nudge land on the same item and users answer by flicking harder at something that cannot hear them. The decay is exponential, not v² / 2a, whose constant deceleration nothing implements.
Rubber-banding is asymptotic, so displacement past a boundary is bounded but never clipped and the surface keeps responding. A hard clamp leaves a dead zone where the finger moves and nothing does, which reads as a hang.
4. Never animate from the target value
Read the live presentation value — the object's position mid-flight — as the start of any new animation, blending the in-flight velocity in. Otherwise you get the snap-back: tap to open a menu, tap again 120ms later, and a close starting from the fully-open value teleports it somewhere it never reached and animates back — a jump then a reversal, at the moment the user is watching for confirmation.
Drive 2D motion with independent X and Y springs: one spring on the scalar distance imposes a single velocity profile on both axes, so they finish together however far each travelled and the path is a straight diagonal rather than a curve.
5. Gesture mechanics
Require 10px of travel before locking to an axis: a finger pressing down always produces incidental movement, and a zero threshold turns taps into drags. Add ~10px of hit padding. Highlight on pointer-down, commit on pointer-up, cancel by dragging away and re-arm by returning. Detect plausible gestures in parallel and cancel the losers; chaining through failure requirements is the classic 300ms feeling. Decide reverse-versus-commit on velocity sign, position only as a tiebreaker. Preserve the grab offset, ignore other pointer IDs, and dismiss above 0.11 px/ms as well as on distance.
6. Easing, durations, and reduced motion
Entering and exiting take ease-out; moving or morphing takes ease-in-out; hover and colour take ease; constant motion takes linear. ease-in on interface motion is a defect — it begins at zero velocity, so for the first 100ms after the user acts almost nothing happens, precisely the window in which they want confirmation.
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);
--ease-drawer: cubic-bezier(0.32, 0.72, 0, 1);
Button press 100–160ms, tooltip 125–200ms, dropdown 150–250ms, modal or drawer 200–500ms. UI feedback stays under 300ms; past that the motion stops reading as a consequence of the action and starts reading as a wait. Staggers run 30–80ms, capped near 300ms.
Reduced motion means gentler and fewer, not zero: a ~200ms opacity cross-fade replacing transform motion, parallax and overshoot and looping dropped, colour and progress kept. Setting everything to 0ms removes the state-change signal along with the motion. Avoid periodic motion near 0.2Hz and large moving backgrounds — that band mimics low-frequency self-motion cues, and the mismatch with a stationary body produces nausea.
7. Transform discipline and performance
Animate transform and opacity only; clip-path is sanctioned and height tolerated for accordions. Everything else triggers layout or paint every frame. Never scale(0) — enter from scale(0.90)–scale(0.97) with opacity: 0, press between 0.95 and 0.98. transform-origin anchors to the trigger, modals excepted; crossfade blur stays 2–20px.
CSS animations and WAAPI run off the main thread for compositor-friendly properties, so they hold 60fps through main-thread work that would stall a JavaScript requestAnimationFrame loop — which matters most during a route transition, as the incoming route parses and hydrates. element.animate() gives that control at compositor performance. Never drive a child's transform from a custom property set on an ancestor: those inherit, so each update invalidates style across the whole subtree. Use @starting-style for mounts, not a double-rAF flip.
Rules
MUST NOT — Do not give a spring a damping ratio below 1.0 unless the gesture that triggered it carried momentum in the direction the element is now moving.
Why: Overshoot is the visible consequence of kinetic energy the object could not shed instantly, so the eye accepts it only when the interaction actually supplied that energy. A tap is an impulse of essentially zero duration at a point that has nothing to do with the element’s destination, so an overshoot afterwards has no cause the eye can attribute and the brain files it under "glitch" rather than physics. A modal that springs 4% past its final size before settling is honestly described as a modal that is briefly the wrong size.
Source: Vishwakarma motion-physics reference, section 2
Exceptions:
- Rotation, which carries angular momentum, tolerates a small bounce (damping 0.8) even when the trigger was discrete.
Incorrect:
// opened by a tap
animate(modal, { scale: 1 }, { type: "spring", bounce: 0.4, duration: 0.3 })
Correct:
// opened by a tap
animate(modal, { scale: 1 }, { type: "spring", bounce: 0, duration: 0.3 })
MUST NOT — Do not start an animation from a hard-coded target value; read the live presentation value and inherit the in-flight velocity when re-targeting.
Why: The failure this prevents is the snap-back. If a user taps to open a menu and taps again 120ms later, and the close animation starts from the fully-open value, the menu teleports to a position it never reached and then animates back — a jump forward followed by a reversal, at the exact moment the user is watching to confirm their input registered, which reads as the interface fighting them. Motion libraries that own the value re-target correctly by default; fixed keyframe sequences and CSS @keyframes cannot, which is why interruptible motion must be spring-driven over live state.
Source: Vishwakarma motion-physics reference, section 7
Incorrect:
el.animate([{ transform: "translateY(0)" }, { transform: "translateY(-100%)" }], 300)
Correct:
animate(el, { y: "-100%" }, { type: "spring", bounce: 0, duration: 0.3 }) // re-targets from the live value
MUST NOT — Do not apply ease-in to an entrance, exit, or state transition; resolve non-spring easing to ease-out, ease-in-out, or the drawer curve.
Why: ease-in begins at zero velocity, so for the first 100ms after the user acts almost nothing happens — precisely the window in which they are looking for confirmation that their input registered. The total duration is identical to an ease-out version, but the perceptible part of the motion has been deferred to the end, so the interface feels unresponsive for a reason nobody can name from the timing values alone. Entrances and exits are objects with mass arriving at or leaving a stop, and that is deceleration.
Source: Vishwakarma motion-physics reference, section 9
Incorrect:
.toast { transition: transform 200ms ease-in; }
Correct:
.toast { transition: transform 200ms var(--ease-out); }
MUST — Parameterise every spring as damping ratio and response — or the equivalent bounce and duration — never as mass, stiffness, and damping coefficient.
Why: The physical triple is coupled: raising stiffness to shorten the response also lowers the effective damping ratio, so an edit meant to make the motion faster silently adds overshoot and the two values get chased against each other. Damping ratio and response are independent and each maps to something perceivable — how far it overshoots, and how long it takes. The same damping ratio also produces the same perceived overshoot on iOS, Android and web, so a value tuned in physics translates and one tuned in API parameters does not.
Source: Vishwakarma motion-physics reference, section 1
Incorrect:
animate(el, { y: 0 }, { type: "spring", mass: 1, stiffness: 320, damping: 22 })
Correct:
animate(el, { y: 0 }, { type: "spring", bounce: 0.2, duration: 0.3 }) // damping 0.8, response 0.3s
MUST — Pass the measured release velocity as the spring’s initial velocity at every gesture-to-spring handoff, measuring it over the last 50–100ms of pointer samples.
Why: If the spring starts from rest, the object decelerates to zero at the instant of release and then accelerates again from nothing, so the eye reads a stop-and-restart in the middle of one continuous motion and the illusion that the user was ever touching the object collapses. The 50–100ms window matters because the final two pointer events are one frame apart and dominated by sensor noise: a finger moving steadily at 800px/s can report 40px/s or 3000px/s depending on which pair you catch.
Source: Vishwakarma motion-physics reference, section 3
Incorrect:
onRelease(() => animate(el, { y: target }, { type: "spring", bounce: 0, duration: 0.3 }))
Correct:
const v = velocityOver(samples, 80) // px/s across the last ~80ms
animate(el, { y: target }, { type: "spring", bounce: 0, duration: 0.3, velocity: v })
MUST — Choose a snap target by proximity to the projected endpoint, computed as (v / 1000) * d / (1 - d), rather than by proximity to the release point.
Why: Snapping from where the finger lifted throws away the energy the user put in, so a hard flick and a gentle nudge from the same position land on the same item — the interface stops responding to force, and users answer by flicking harder and harder at something that cannot hear them. The exponential form is also load-bearing: platform scroll views multiply velocity by a decay factor each millisecond, so the kinematic v² / 2a assumes a constant deceleration that nothing implements and lands too short for gentle flicks and too long for hard ones.
Source: Vishwakarma motion-physics reference, section 4
Incorrect:
const target = nearest(snapPoints, current)
Correct:
const projected = current + (releaseVelocity / 1000) * 0.99 / (1 - 0.99)
const target = nearest(snapPoints, projected)
MUST — Keep every UI feedback animation under 300ms; only large surfaces such as drawers, sheets, and modals may exceed it.
Why: Past roughly 300ms the animation stops being perceived as a consequence of the action and starts being perceived as a wait: the causal link between input and response weakens, and the user begins attributing the delay to the system rather than to the object. Duration otherwise scales with distance and area, because a large surface moving quickly implies an implausibly large force and reads as violent, which is the only reason a drawer earns 400ms — its travel genuinely takes time.
Source: Vishwakarma motion-physics reference, section 10
Incorrect:
.button:active { transition: transform 450ms var(--ease-out); }
Correct:
.button:active { transition: transform 120ms var(--ease-out); }
MUST — Animate only transform and opacity — with clip-path sanctioned and height tolerated for accordions — and never scale to zero.
Why: width, top, margin, box-shadow, and filter on large surfaces trigger layout or paint on every frame, so they drop frames on the mid-range devices your users actually own rather than on the workstation the motion was tuned on. scale(0) is a separate defect: an element scaled to zero has no dimensions, so its interior detail is meaningless and it reads as a point of light rather than an object, with an enormous perceived acceleration on the way out. Entrances start between scale(0.90) and scale(0.97), close enough to be recognisably themselves from the first frame.
Source: Vishwakarma motion-physics reference, section 14
Incorrect:
@keyframes in { from { width: 0; height: 0; } to { width: 320px; height: 200px; } }
Correct:
@keyframes in { from { transform: scale(0.95); opacity: 0; } to { transform: none; opacity: 1; } }
MUST — Give every motion path a prefers-reduced-motion branch that becomes a roughly 200ms cross-fade, rather than removing the transition entirely.
Why: Setting every transition to 0ms removes the state-change signal along with the motion, and an interface where things appear and disappear instantly is harder to follow, not easier. The branch drops what actually causes harm — parallax, overshoot, looping motion, anything moving independently of the user’s scroll — and keeps opacity, colour, and progress, which carry information. Periodic motion near 0.2Hz over a large field of view is the specific combination associated with vestibular discomfort, because it mimics the low-frequency self-motion cues the vestibular system reads and the mismatch with a stationary body produces nausea.
Source: Vishwakarma motion-physics reference, section 11
Incorrect:
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
Correct:
@media (prefers-reduced-motion: reduce) {
.sheet { transition: opacity 200ms var(--ease-out); transform: none; }
}
MUST — Run the frequency gate before choosing any duration, curve, or spring, and accept "build nothing" as its answer for high-frequency interactions.
Why: Habituation works against the animation. A 200ms transition experienced once is a pleasing detail; experienced 200 times in a working day it is 40 seconds of waiting and a source of low-grade irritation the user cannot articulate, because charm decays with exposure and cost does not. The most common motion defect in generated interfaces is therefore not a badly-tuned animation but a well-tuned animation on something that should have been instant, and a command palette that eases open in 150ms is delightful in a demo and is why a power user switches tools.
Source: Vishwakarma motion-physics reference, section 12
Incorrect:
.command-palette { transition: opacity 150ms, transform 150ms; }
Correct:
.command-palette { /* opened 100+ times a day: no transition */ }
Before reporting completion
Run these checks against your own output. Answer each question explicitly rather than assuming the answer, because the point of the exercise is to notice what you did not notice while building.
Check durations, easings, and reduced-motion coverage. (blocking)
python3 scripts/check_motion.py .
Confirm springs, handoffs, projections, and gesture ownership follow the physics. (blocking)
- Is every spring stated as damping ratio and response (or bounce and duration), and does every damping ratio below 1.0 belong to a gesture that supplied momentum in that direction?
- Does every gesture-to-spring handoff pass a release velocity measured over 50–100ms of samples, and does any relative-velocity computation guard a near-zero target − current denominator?
- Is every snap target chosen by proximity to the projected endpoint from (v / 1000) * d / (1 - d), rather than to the release point?
- Does any animation start from a hard-coded target value instead of the live presentation value, and does any re-target discard the in-flight velocity?
- Do drags require ~10px before committing to an axis, preserve the pointer-down grab offset, ignore additional pointer IDs, and decide commit-versus-reverse on velocity sign with position only as a tiebreaker?
Confirm each animation earned its existence, its purpose, and its duration. (blocking)
- For each animation in the diff, how often will a real user see it, and did the frequency gate return "build nothing" for anything at the top of that scale?
- Which single purpose does each animation name — feedback, spatial consistency, state indication, preventing a jarring change, explanation, or delight — and is anything claiming two or none?
- Is every UI feedback animation under 300ms, with only large surfaces exceeding it, and do list staggers sit in the 30–80ms band with the total capped near 300ms?
- Does any easing resolve to ease-in, or to a curve outside ease-out, ease-in-out, and the drawer curve?
Confirm the reduced-motion branch and the compositing path are both real.
- With prefers-reduced-motion enabled, has every motion path been exercised, and does each become a ~200ms cross-fade rather than nothing at all?
- Does any looping motion sit near 0.2Hz, and does any full-viewport background animate continuously under any setting?
- Are only transform, opacity, clip-path, and accordion height animated, with no scale(0), entrances between 0.90 and 0.97, presses between 0.95 and 0.98, and crossfade blur between 2px and 20px?
- Is any CSS custom property that drives a descendant transform being set on an animating ancestor, and do mount animations use @starting-style rather than a double-rAF class flip?
Further reference
These are not loaded by default. Read one only when its question is the question you currently have.
references/spring-and-gesture-physics.md— How do I parameterise a spring, when is overshoot legitimate, how do I hand a gesture off to a spring without a visible stop, where should a fling land, how does rubber-banding work, and what are the thresholds and ownership rules a gesture recogniser needs?references/timing-easing-and-performance.md— Which easing curve does this motion take, how long should it run, how do I decide whether to animate it at all, what does the reduced-motion branch replace it with, and which properties and APIs keep it off the main thread?