Springs, not curves: motion that survives a change of mind
How spring physics works, why it handles interrupted gestures better than duration and easing, and the exact parameters to use in SwiftUI, Compose and Reanimated.

Most interface motion is still specified the way slide transitions were: a duration and an easing curve. Move this card over 300 ms, ease out. It works as long as nobody touches anything. The moment a person grabs the card mid-flight, flicks it, or changes their mind, the curve has no answer, because it only knows where it started and how long it has left.
A spring answers a different question. It does not know how long it will take. It knows where it should end up, how hard it is pulled there, and how much friction slows it down. That is why the motion in iOS, in Material 3 Expressive and in most well-made React Native apps now comes from springs, and why we default to them in our own work.
Things to try in the playground above:
- Tap Reverse mid-flight. Both dots start moving, then the target flips after 150 ms. In the chart the spring keeps its momentum, overshoots a little and swings back in one smooth line. The curve stops dead and starts a new 400 ms ease from zero speed, which leaves a sharp corner in its line.
- Drag the black dot and throw it. The spring takes your release speed as its starting velocity. Throw the outlined dot the same way: the curve ignores how fast you moved and plays its fixed 400 ms ease.
- Switch between Gentle, Snappy and Bouncy, then move the damping slider slowly. Watch the damping ratio cross 1.0 and the overshoot disappear.
- Turn on Reduce Motion in your system settings and reload. The dots jump straight to their targets and the chart carries the explanation instead.
Why duration and easing break under interruption
A curve is a function of time: at 40% of the duration, show 70% of the distance. To interrupt it, you have two bad options. You can let it finish, which feels like the interface is ignoring you. Or you can start a new curve from the current position, which resets the speed to zero for one frame. People see that as a hitch, even if they cannot name it.
A spring is a function of state: current position, current velocity and target. Each frame it computes a force and updates velocity, then position. If the target changes, nothing resets. The velocity the object already has carries into the new motion. Apple's WWDC23 session on springs puts it plainly: springs are "the only type of animation that maintains continuity both for static cases and cases with an initial velocity."
A curve plays a recording. A spring simulates an object. Only the object can be grabbed.
Three physical numbers, one that matters
A spring has three physical parameters. Stiffness is how hard the spring pulls toward the target; higher means faster. Damping is friction; higher means less oscillation. Mass is inertia; higher means slower to start and slower to stop. The demo integrates exactly this model, force = −stiffness × displacement − damping × velocity, in one-millisecond steps.
The three numbers interact, which makes them hard to tune by hand. The number that actually decides the character of the motion is the damping ratio: damping divided by 2 × √(stiffness × mass). It is a pure ratio, so it reads the same at any speed.
| Damping ratio | Name | What you see | Good for |
|---|---|---|---|
| Below 1 | Underdamped | Overshoots, then settles with one or more bounces | Playful confirmations, drag releases, sheets that snap |
| Exactly 1 | Critically damped | Fastest arrival with no overshoot | Most layout and navigation changes |
| Above 1 | Overdamped | No overshoot, slow creep at the end | Rarely useful in UI; feels sluggish |
Once the ratio is fixed, stiffness (or a perceived duration) sets the tempo. That is why every modern platform API has moved to two friendlier parameters: one for bounciness, one for speed.
Apple: duration and bounce
SwiftUI's spring(duration:bounce:blendDuration:) takes a perceptual duration and a bounce. The documentation describes bounce 0 as a critically damped spring, positive values up to 1.0 as increasingly bouncy, and negative values down to −1.0 as overdamped. The defaults are a duration of 0.5 seconds and a bounce of 0.
The mapping to physics is simple. With mass fixed at 1, stiffness is (2π ÷ duration)², and for bounce of 0 or more the damping ratio is 1 − bounce. The playground shows this as "Apple bounce". In the same WWDC23 session Apple also advises caution with bounce values above about 0.4 for UI elements, and recommends bounce 0 when in doubt.
withAnimation(.spring(duration: 0.4, bounce: 0.15)) {
isExpanded.toggle()
}
// Presets built from the same model
withAnimation(.snappy) { selection = item }
Jetpack Compose: damping ratio and stiffness
Compose's spring() takes a dampingRatio and a stiffness, plus an optional visibility threshold. The Spring object ships named constants, which make code reviews easier than raw numbers.
| Constant | Value |
|---|---|
| Spring.DampingRatioHighBouncy | 0.2 |
| Spring.DampingRatioMediumBouncy | 0.5 |
| Spring.DampingRatioLowBouncy | 0.75 |
| Spring.DampingRatioNoBouncy | 1.0 |
| Spring.StiffnessVeryLow | 50 |
| Spring.StiffnessLow | 200 |
| Spring.StiffnessMediumLow | 400 |
| Spring.StiffnessMedium | 1500 |
| Spring.StiffnessHigh | 10000 |
val offset by animateDpAsState(
targetValue = if (expanded) 0.dp else 120.dp,
animationSpec = spring(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow
),
label = "offset"
)
Material 3 Expressive goes further. Google describes it as introducing "a new motion physics system" built into the Material theme. In Compose this is MotionScheme, with MotionScheme.standard() and MotionScheme.expressive(). Each scheme hands out springs in two families: spatial specs for position, size and shape, and effects specs for colour and opacity, each in fast, default and slow. Components read these from the theme, so a whole app can change its motion character in one place.
In the current Compose Material 3 source, the expressive default spatial spring has a damping ratio of 0.8 at stiffness 380, while the standard one is 0.9 at 700. Every effects spring in both schemes uses a damping ratio of 1.0. That last detail is a good rule on its own: colour and opacity should never overshoot, because an overshooting fade reads as a flicker.
React Native Reanimated: two ways to configure
Reanimated 4's withSpring accepts either a physics config or a duration-based config. Physics: stiffness (default 900), damping (default 120) and mass (default 4), which works out to a damping ratio of exactly 1. Duration-based: duration (default 550 ms) and dampingRatio (default 1). If you pass both, duration and dampingRatio win. The config also takes an initial velocity, overshootClamping, energyThreshold and reduceMotion, which defaults to following the system setting.
// Duration-based: easiest to reason about with designers
offset.value = withSpring(expanded ? 0 : 120, {
duration: 400,
dampingRatio: 0.8,
});
// After a pan gesture: hand the release speed to the spring
const pan = Gesture.Pan().onEnd((e) => {
offset.value = withSpring(0, { velocity: e.velocityX });
});
Where we still use curves
Springs are not a religion. Some motion has no object and no gesture behind it: a progress bar tied to real progress, a looping loader, a scrubbed video timeline, a Lottie file. Those are functions of time or of external state, and a duration is the honest model. We also keep simple fades on short linear or ease-out curves when the platform makes that easier, because opacity has no momentum to preserve.
The rule we use: if a person can touch it, drag it or interrupt it, it moves on a spring. If it reports something the system is doing, it moves on time.
Starting values that work
- Default to a damping ratio of 1 (bounce 0)It gives the fastest settle with no overshoot. Most screens never need anything else.
- Add bounce only where something is released or confirmedA sheet snapping to a detent, a card thrown back into a stack, a toggle landing. Keep it modest; Apple suggests caution above roughly 0.4.
- Never let colour or opacity overshootUse a damping ratio of 1 for every non-spatial property, as Material's effects springs do.
- Set tempo with one numberPick a perceptual duration (SwiftUI, Reanimated) or a stiffness constant (Compose) and reuse it as a token across the app.
- Always pass gesture velocity on releaseIt is the difference between a UI that follows your hand and one that only follows instructions.
- Respect Reduce MotionSwap large spatial springs for a cross-fade or an instant change. Reanimated's reduceMotion follows the system setting by default.
Motion tokens belong in the design system next to colour and type. When we hand an app over, the springs are named, documented and shared between design files and code, so the motion stays the same when the team grows.
Sources
- Animate with springs (WWDC23) · Apple Developer
- spring(duration:bounce:blendDuration:) · Apple Developer
- Spring (Compose animation core) · Android Developers
- Customize animations · Android Developers
- MotionScheme · Android Developers
- Androidify: Building delightful UIs with Compose · Android Developers Blog
- withSpring · Software Mansion
- Motion: how it works · Material Design


