Flutter Pro Design

Small details that build taste in Flutter.

curated by Kamran BekirovKamran Bekirov

Make animations feel alive with springs

A spring is how things move in real life. When you pull a rubber band and let go, it doesn't stop instantly. It snaps back, wobbles a bit, then settles. Flutter lets you make animations move like that instead of moving at a fixed speed.

Flutter's spring has 3 properties:

SpringDescription(
  mass: 1.0,
  stiffness: 100.0,
  damping: 10.0,
)

mass is how heavy the thing is. Heavier = slower to start, slower to stop. Like pushing a bowling ball vs a tennis ball.

  • Low mass (0.3): quick, light, snappy
  • High mass (2.0): slow, heavy, lazy

stiffness is how strong the spring pulls back. Like a rubber band, thin one is weak, thick one is strong.

  • Low stiffness (50): slow, soft, takes time to settle
  • High stiffness (500): fast, sharp, snaps into place quickly

damping is how fast the bouncing stops. Like bouncing a ball on sand vs concrete.

  • Low damping (3): bounces many times before stopping
  • High damping (30): barely bounces, settles almost immediately
  • Zero damping: bounces forever (never use this)

The most direct way to drive a spring is through an AnimationController. Hand animateWith a SpringSimulation and the controller carries the value from start to end on the spring's curve:

_controller.animateWith(
  SpringSimulation(
    SpringDescription(
      mass: 0.5, 
      stiffness: 200, 
      damping: 18,
    ),
    _controller.value, // start
    1.0,               // end
    0,                 // initial velocity
  ),
);

Use it in a gesture's onEnd, or anywhere you'd call .forward() / .animateTo(). Pass the release velocity in and the spring carries the user's motion through, so, for example, drag-to-dismiss sheets and swipeable cards feel alive instead of mechanical.

One catch: use AnimationController.unbounded(vsync: this). A plain AnimationController clamps values to [0, 1] and silently eats the spring's overshoot, so the bounce disappears.

The same spring drops into anything scrollable. Every scrollable widget accepts a physics param, and inside that physics there's a spring getter you can override:

class SnappyPagePhysics extends PageScrollPhysics {
  const SnappyPagePhysics({super.parent});
  
  @override
  SpringDescription get spring {
    return SpringDescription(
      mass: 0.5,
      stiffness: 200,
      damping: 18,
    );
  }
  
  @override
  SnappyPagePhysics applyTo(ScrollPhysics? ancestor) {
    return SnappyPagePhysics(
      parent: buildParent(ancestor),
    );
  }
}

Then pass it in:

PageView(
  physics: const SnappyPagePhysics(),
  children: pages,
)

An example from expen.app, a PageView with a custom spring:

This works on anything that scrolls:

  • PageView, TabBarView (snap between pages)
  • ListWheelScrollView (snap to items in pickers)
  • CarouselView (snap between items)
  • ListView, GridView, SingleChildScrollView, CustomScrollView (edge bounce-back)
  • NestedScrollView, ReorderableListView

For snapping widgets, you feel the spring every swipe. For free-scrolling lists, the spring drives edge bounce-back, but only under BouncingScrollPhysics (iOS default). Android's ClampingScrollPhysics glows instead.

Route transitions are the third surface. Override TransitionRoute.createSimulation() to return a SpringSimulation instead of a duration-based curve, and the route enters and exits on the spring.

There are no correct values. Every app is different. Tweak until it feels right. If hand-tuning sounds like work, the motor package ships presets matching each platform's design system:

// iOS feel
SingleMotionBuilder(
  // autocomplete to see other variants too
  motion: CupertinoMotion.bouncy(), 
  value: target,
  builder: (context, v, _) {
    return Transform.translate(
      offset: Offset(v, 0),
      child: child,
    );
  },
);
  
// Material 3 feel
SingleMotionBuilder(
  // autocomplete to see other variants too
  motion: MaterialSpringMotion.expressiveSpatialDefault(),
  value: target,
  builder: (context, v, _) {
    return Transform.translate(
      offset: Offset(v, 0),
      child: child,
    );
  },
);

MaterialSpringMotion splits spatial (position, size) from effects (opacity, color), each available in standard or expressive at fast, default, or slow. Match the platform you're emulating, or mix on purpose.

The best spring is one that feels right.