Flutter spring animations that feel alive: use SpringSimulation, animateWith, and gesture velocity handoff to build interruptible motion that never snaps.
A designer at Shpper once handed me a build back with one note: "the sheet feels cheap." No frame counter, no bug, nothing you could screenshot. It just felt like plastic. I spent the better part of a day proving to myself the animation was "correct" — 300ms, Curves.easeInOut, textbook — before I accepted that correct and good are different words. The animation was fine. It was also lifeless, and every user could feel it even though not one of them could name it.
That gap is the whole subject of this post. Almost every Flutter animation tutorial ends its chapter the same way: wrap a value in AnimatedContainer, pick Curves.easeInOut, ship it. It's fine for a demo. It's also why so many apps feel subtly dead — every transition takes the same 300ms, every card slides in on the same curve, and the second a user swipes twice in a row the thing stutters and snaps back to a position it had already left. The problem isn't that curves are wrong. It's that a curve is a fixed recording of motion from zero to one over a fixed time, and real interfaces aren't fixed. Users grab things, fling them, change their minds halfway. Motion that can't respond to that will always feel like a cutscene playing over the app instead of the app itself moving.
Below is the tier above curves: spring physics, velocity handoff, and interruptible motion you can retarget mid-flight without a single visible snap. Everything here is built on Flutter's own physics primitives — SpringSimulation, FrictionSimulation, and AnimationController.animateWith — so there's no package to add and nothing to break when the framework updates. If you've been shipping AnimatedContainer and wondering why the results feel one notch below the apps you admire, this is the notch.
A curve is a pure function: given t from 0 to 1, return an eased value from 0 to 1. easeInOut is the default in a hundred tutorials because it looks "smooth" in isolation. The trouble is that smoothness has no memory and no context.
Two problems fall out of that:
easeInOut, all 300ms — and the app develops a flat, uniform texture. Nothing feels light, nothing feels heavy. Physical objects don't work that way; a business card and a filing cabinet do not move on the same curve.Curves are a description of motion. Springs are a model of motion. That distinction is the whole post. A description says "be at this value at this fraction of the clock." A model says "here are the forces; wherever you are and however fast you're moving, this is what happens next." Only the second one can absorb a user grabbing the object halfway through.
Here's my actual opinion, and I'll defend it: for anything a finger can touch, Curves.easeInOut is the wrong default, and Flutter picking it as the ubiquitous example has quietly taught a generation of developers to ship animations that fight their users. The default should have been a spring. A curve is the right tool for exactly one job — a non-interruptible entrance with a fixed duration — and we reach for it for everything because it's the first thing the docs show. If you only remember one line from this post: the fixedness of a curve is a feature for cutscenes and a bug for anything interactive.
Flutter gives you three rungs, and most developers stall on the first two.
AnimatedContainer, AnimatedOpacity, AnimatedPositioned, AnimatedAlign, TweenAnimationBuilder. You change a target value, the widget interpolates for you. Zero ceremony. Perfect for "this thing moved to a new place, don't care about the details."AnimationController plus a Tween. You own the timeline: forward(), reverse(), repeat(), status listeners. This is where most "serious" Flutter animation code lives, and it's genuinely the right tool for looping, staged, and status-driven motion.AnimationController, but instead of animateTo with a duration you call animateWith and hand it a Simulation. Now the duration doesn't exist. The animation runs until the physics settle.The tell that you've outgrown the first two rungs is simple: the moment the user's input can arrive before the previous animation finished. A toggle that flips on tap is fine on rung two. A draggable bottom sheet, a swipeable card, a pull-to-refresh, a dismissible row — anything the user physically manipulates — needs rung three, because the user supplies a velocity and your animation has to continue from that velocity, not restart at zero.
A quick way to audit an existing screen: for every animation, ask "can a second gesture land while this is still running?" Every yes is a candidate for physics. Every no can stay on a curve, and that's completely fine — the goal isn't to spring-ify everything, it's to match the model to the interaction.
A spring in Flutter is described by three numbers via SpringDescription: mass, stiffness, and damping. You rarely tune all three by hand — SpringDescription.withDampingRatio is the humane API, because damping ratio maps to intuition: below 1 it overshoots and bounces, exactly 1 is critically damped (fastest settle with no overshoot), above 1 is sluggish and heavy.
Here's the core pattern. A value that springs to a target, carrying whatever velocity you give it:
class Springy extends StatefulWidget { const Springy({super.key}); @override State<Springy> createState() => _SpringyState();}class _SpringyState extends State<Springy> with SingleTickerProviderStateMixin { late final AnimationController _controller = AnimationController.unbounded(vsync: this); static const _spring = SpringDescription( mass: 1, stiffness: 500, damping: 20, ); void _springTo(double target, {double velocity = 0}) { final simulation = SpringSimulation( _spring, _controller.value, // start where we currently are target, // end here velocity, // carry incoming velocity ); _controller.animateWith(simulation); } @override void dispose() { _controller.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return GestureDetector( onTap: () => _springTo(_controller.value > 0.5 ? 0 : 1), child: AnimatedBuilder( animation: _controller, builder: (context, child) => Align( alignment: Alignment(0, _controller.value * 2 - 1), child: child, ), child: const FlutterLogo(size: 64), ), ); }}Two details that matter more than they look:
AnimationController.unbounded. A spring can overshoot past 1.0 before settling. A normal clamped controller would chop that overshoot off and kill the bounce. Unbounded lets the physics express itself; you clamp at the read site if you must._controller.value, not 0. The simulation begins wherever the widget currently sits. This one line is what makes the next section possible.With stiffness: 500, damping: 20 you get a snappy, barely-overshooting settle — the kind of motion that feels "responsive." Drop damping to 8 and it wobbles like jelly. Same code, different feel, and no duration anywhere. That's the point: the animation is fast when the distance is short and takes longer when it's far, exactly like a real object. A curve can't do that without you manually scaling the duration by distance.
If numbers-by-feel makes you nervous, prefer the damping-ratio constructor and tune two intuitive knobs instead of three abstract ones:
final spring = SpringDescription.withDampingRatio( mass: 1, stiffness: 500, ratio: 0.8, // <1 overshoots, 1 critical, >1 heavy/sluggish);
I keep a tiny catalogue of these in a shared file — one "snappy UI" spring, one "playful bouncy" spring, one "heavy drawer" spring — and name them by feel. Naming the springs is a surprisingly large upgrade to a design system, because it turns "make it bouncier" from a guessing game into swapping one named constant.
This is the section that separates shipped-looking apps from tutorial apps.
Imagine a bottom sheet snapping to "open." Mid-flight, the user swipes to close it. The naive fix is to start a new close animation. But you already have a widget moving upward at some velocity, and now you want it to end up down. If you restart from zero velocity, the sheet visibly stops dead in the air for a frame, then reverses. Users don't consciously notice the frame — they just feel that the app is "cheap."
The physics answer is to build a new simulation from the current position and current velocity, aimed at the new target. Because a SpringSimulation accepts both, retargeting is almost free:
void _retarget(double newTarget) { final currentVelocity = _controller.velocity; // live velocity, mid-flight final simulation = SpringSimulation( _spring, _controller.value, newTarget, currentVelocity, ); _controller.animateWith(simulation);}_controller.velocity reports the instantaneous velocity of the running simulation. Feed it into the replacement simulation and the transition is seamless — the object was moving up at 3 units/sec, and now it's a spring that happens to be moving up at 3 units/sec but pulling down. It decelerates, reverses, and settles, all continuously. There is no zero-velocity frame because there is no restart. You are describing forces, and forces compose.
On a recent project we had a card stack where users flick cards away and occasionally flick a second one before the first settled. The bug reports all said "it jumps." Every one of them was a restart-instead-of-retarget. Switching to animateWith on the live velocity closed the whole class of bug — not one fix per gesture, one fix for the concept.
A subtlety worth naming: calling animateWith again automatically cancels the currently running simulation, so you don't have to stop() first. But you do have to read _controller.velocity before you call it, because once the new simulation is installed, velocity reports the new one's velocity. Read live velocity, then hand off — in that order, every time.
Gestures are where physics pays off loudest, because GestureDetector and Drag hand you a velocity in pixels per second at the exact moment the finger lifts. That number is the bridge between the user's muscles and your simulation.
The pattern is three-phase:
void _onDragUpdate(DragUpdateDetails d) { // Finger owns the value directly. _controller.value += d.primaryDelta! / _extent;}void _onDragEnd(DragEndDetails d) { // Convert pixels/sec to the controller's normalized units/sec. final pixelsPerSecond = d.velocity.pixelsPerSecond.dy; final normalizedVelocity = pixelsPerSecond / _extent; final goingDown = normalizedVelocity > 0; final target = goingDown ? 0.0 : 1.0; final simulation = SpringSimulation( _spring, _controller.value, target, normalizedVelocity, // the handoff ); _controller.animateWith(simulation);}Two things I got wrong for embarrassingly long:
velocity.pixelsPerSecond is in pixels; your controller is usually normalized 0–1. If you forget to divide by the drag extent, a hard fling launches the object into orbit. Always convert into the controller's own coordinate space.dy is downward. A fling up is negative. Get the sign wrong and hard flings snap to the opposite target — which looks like the app fighting the user. It's the single most confusing gesture bug to debug because a gentle fling below the threshold behaves correctly and only hard flings misfire.There's also a real design decision hiding in step 2: how do you pick the target? The mistake is to snap purely on position — "it's more than halfway closed, so dismiss." The better rule combines position and velocity: a fast flick should dismiss even from 20%, because the user clearly meant it, while a slow drag released at 60% might snap back. Flutter's own scroll physics work exactly this way, which is why a light flick on a list travels so much farther than the finger did.
For the pure "throw it and let it coast" feel — scroll lists, momentum panels, a dial that spins down — reach for FrictionSimulation or ClampingScrollSimulation instead of a spring. There's no target, just deceleration from the handoff velocity. Same animateWith, different physics model:
void _onDragEndCoast(DragEndDetails d) { final v = d.velocity.pixelsPerSecond.dx / _extent; final simulation = FrictionSimulation(0.135, _controller.value, v); _controller.animateWith(simulation);}The first argument is the drag coefficient — lower coasts farther, higher stops sooner. Picking it is the same "name it by feel" exercise as picking a spring.
Once you have several elements moving, the temptation is one controller per element and a pile of Future.delayed calls. That's how you get sequences that desync the instant a frame drops.
Use a single controller as a clock and slice it with Interval. Each child animates over a sub-window of the same 0–1 timeline, so they can never drift apart:
final _controller = AnimationController( duration: const Duration(milliseconds: 600), vsync: this,);late final _title = CurvedAnimation( parent: _controller, curve: const Interval(0.0, 0.5, curve: Curves.easeOut),);late final _subtitle = CurvedAnimation( parent: _controller, curve: const Interval(0.2, 0.7, curve: Curves.easeOut),);late final _cta = CurvedAnimation( parent: _controller, curve: const Interval(0.4, 1.0, curve: Curves.easeOutBack),);
The title runs in the first half, the subtitle overlaps into the middle, the CTA lands last with a little easeOutBack kick. One forward() drives all three, and because they share the clock, a slow frame slows all of them together instead of tearing the choreography apart. This is the one place I happily keep curves — an entrance has a fixed duration and no interruption, so springs buy you nothing. Match the tool to whether the user can interrupt it.
For genuinely long or list-based stagger, don't hand-write intervals — flutter_staggered_animations or flutter_animate express the same idea declaratively and read far better at ten items than a wall of Interval constants. But understanding the single-clock model is what stops you from building a Future.delayed swamp, and it's what lets you debug those packages when a stagger looks off.
Here's the trap. You wire up beautiful physics, then wrap your whole screen in an AnimatedBuilder and rebuild forty widgets every frame. Now your gorgeous 120Hz spring drops frames and you blame the physics.
The rules I hold to:
AnimatedBuilder/ListenableBuilder should wrap the smallest possible subtree. Everything static goes in the child: argument, which is built once and passed through untouched. Only the builder closure re-runs per frame.Transform, Align, or Opacity in the builder over rebuilding layout. Transforms and opacity are cheap; relaying out a subtree 60–120 times a second is not. If you're animating padding or EdgeInsets, ask whether a Transform.translate would look identical and skip layout entirely.RepaintBoundary. Without it, an animating widget can dirty its parent's layer and force the whole thing to repaint. With it, the compositor isolates the moving pixels. On one list-heavy screen this alone took us from a stuttery scroll to a locked frame rate on a mid-range Android.RepaintBoundary( child: AnimatedBuilder( animation: _controller, builder: (context, child) => Transform.translate( offset: Offset(0, _controller.value * 20), child: child, // built once, never rebuilt ), child: const ExpensiveStaticCard(), ),)
If you take one habit from this section: the child parameter is not decoration. It is the difference between rebuilding a leaf and rebuilding a screen. I've watched a single misplaced AnimatedBuilder boundary turn a buttery interaction into a slideshow, and moving three lines fixed it with no change to the physics at all.
Animations are the cruelest thing to profile because they're smooth on your laptop's profile-mode simulator and janky on the cheap Android in your user's hand. Rules from the field:
flutter run --profile on a physical, low-end device. This is non-negotiable — I keep a deliberately cheap Android around exactly for this.showPerformanceOverlay: true, or the frame charts in DevTools). Two bars: the UI thread (your Dart, including build and layout) and the raster thread (GPU compositing). A tall raster bar means you're overdrawing — usually a missing RepaintBoundary or a Stack full of overlapping opacity. A tall UI bar means you're rebuilding too much in your builder.SpringSimulation settles, but a listener that calls setState unconditionally, or a repeat() you forgot, can keep ticking every frame forever — burning battery and masking real jank. Confirm the controller reaches AnimationStatus.completed and stops, and add an addStatusListener in development if you're unsure.The mental split that saves the most time: UI-thread jank is a Dart/rebuild problem, raster-thread jank is a painting/compositing problem. Look at which bar is tall before you touch anything. Guessing which one it is, and fixing the wrong layer, is the single biggest time sink in animation work.
AnimationController.animateWith(SpringSimulation(...)) the instant a user can interrupt an animation with their own input — draggable sheets, swipeable cards, pull-to-refresh, dismissible rows._controller.value and _controller.velocity (read velocity before you call animateWith) and the snap disappears — one fix for a whole class of "it jumps" bugs.dy), and snap on position and velocity so a fast flick still dismisses.FrictionSimulation for coast-and-stop motion (lists, momentum panels) and springs for target-seeking motion (sheets, toggles, cards).Interval; keep interruptible motion on springs. Match the tool to whether the user can grab it.AnimatedBuilder, pass static content through child, add RepaintBoundary, and profile in profile mode on a cheap real device — never debug mode on your laptop. UI-thread jank is a rebuild problem; raster-thread jank is a painting problem.