Skip to main content

Animation

Implicit animations for the common cases, explicit animations when you need control, and the habits that keep animation off the critical path.

Contents

Choosing an approach

NeedUse
Animate to a new value when state changesImplicit — AnimatedContainer, AnimatedOpacity, AnimatedAlign
Animate an arbitrary value implicitlyTweenAnimationBuilder
Swap one widget for anotherAnimatedSwitcher
Repeat, reverse, pause, or sequenceExplicit — AnimationController
Drive animation from a gestureExplicit, driving the controller's value directly
Move an element between routesHero
Motion that should feel physicalSpringSimulation via controller.animateWith

Start implicit. Reach for a controller when you need to control the animation rather than just declare its endpoint.

Implicit animations

Change a property and the widget animates to it. No controller, no disposal.

AnimatedContainer(
duration: const Duration(milliseconds: 250),
curve: Curves.easeOutCubic,
width: _expanded ? 280 : 140,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: _expanded ? scheme.primaryContainer : scheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(_expanded ? 24 : 12),
),
child: const Text('Details'),
)

TweenAnimationBuilder animates any value that has a Tween:

TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress),
duration: const Duration(milliseconds: 400),
builder: (context, value, child) =>
LinearProgressIndicator(value: value),
)

AnimatedSwitcher cross-fades between children. It compares by widget key, so give the children distinct keys or the switch will not be detected:

AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: isLoading
? const CircularProgressIndicator(key: ValueKey('loading'))
: ResultView(key: ValueKey(result.id), result: result),
)

Explicit animations

An AnimationController produces a double from 0 to 1 over a duration, driven by a Ticker that fires once per frame.

class Pulse extends StatefulWidget {
const Pulse({super.key, required this.child});
final Widget child;

@override
State<Pulse> createState() => _PulseState();
}

class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);

late final Animation<double> _scale = Tween(begin: 1.0, end: 1.08)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));

@override
void dispose() {
_controller.dispose(); // mandatory — a leaked ticker runs forever
super.dispose();
}

@override
Widget build(BuildContext context) =>
ScaleTransition(scale: _scale, child: widget.child);
}

Three rules:

  • vsync must be a TickerProvider. Use SingleTickerProviderStateMixin for one controller, TickerProviderStateMixin for several.
  • Always dispose() the controller. A ticker that outlives its widget keeps producing frames and holds the State in memory.
  • Controller methods: forward, reverse, repeat, stop, reset, animateTo, fling. Each returns a TickerFuture you can await.

Tweens and curves

A Tween maps the controller's 0-to-1 range onto real values; a curve reshapes time.

final offset = Tween<Offset>(begin: const Offset(0, 0.15), end: Offset.zero)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));

final colour = ColorTween(begin: Colors.grey, end: Colors.indigo)
.animate(_controller);

Useful curves: easeOut for entrances, easeIn for exits, easeInOut for moves, easeOutBack for a slight overshoot, Curves.fastOutSlowIn for the Material default. Avoid linear for anything that represents physical motion.

CurveTween chains with Tween when you want the curve applied before the value mapping, which matters for staggering.

Transition widgets

Prefer the built-in *Transition widgets over AnimatedBuilder where one exists — they rebuild only themselves, not your subtree.

WidgetAnimates
FadeTransitionOpacity
ScaleTransitionScale
SlideTransitionFractional offset
RotationTransitionRotation
SizeTransitionExtent along one axis
DecoratedBoxTransitionDecoration
AlignTransitionAlignment

For anything else, AnimatedBuilder with the child argument keeps the expensive subtree out of the rebuild:

AnimatedBuilder(
animation: _controller,
// built once, not per frame
child: const ExpensiveContent(),
builder: (context, child) => Transform.rotate(
angle: _controller.value * math.pi,
child: child,
),
)

Staggered animations

Drive several animations from one controller using Interval, so they stay in sync and cost one ticker.

Animation<double> _step(double begin, double end) => CurvedAnimation(
parent: _controller,
curve: Interval(begin, end, curve: Curves.easeOutCubic),
);

late final _fade = _step(0.0, 0.4);
late final _slide = Tween(begin: const Offset(0, 0.2), end: Offset.zero)
.animate(_step(0.2, 0.7));

Hero transitions

Wrap the same tag on both routes and the framework animates the element between them:

// List page
Hero(tag: 'product-${product.id}', child: ProductThumb(product: product))

// Detail page
Hero(tag: 'product-${product.id}', child: ProductImage(product: product))

Tags must be unique within a route. If the two widgets differ in shape, supply a flightShuttleBuilder to control what is drawn mid-flight. Custom route transitions are covered in Navigation and routing.

Physics-based motion

For motion that continues after a gesture ends, drive the controller with a simulation instead of a duration:

void _settle(double velocity) {
final simulation = SpringSimulation(
const SpringDescription(mass: 1, stiffness: 500, damping: 30),
_controller.value,
0, // target
velocity,
);
_controller.animateWith(simulation);
}

This is what makes a dismissible sheet feel right: the animation inherits the velocity of the finger that threw it.

Performance

  • Animate the cheapest property. Transform and opacity are handled by the compositor; animating layout properties such as width forces relayout of the subtree every frame.
  • Pass child to builders so the static part is built once.
  • Add a RepaintBoundary around an animating subtree so its repaints do not dirty the rest of the screen — but not around everything, since each boundary costs a layer.
  • Avoid Opacity for animation; FadeTransition and AnimatedOpacity use cheaper paths. A plain Opacity between 0 and 1 triggers saveLayer.
  • Do not animate while offscreen. Stop controllers when a route is not visible or the app is backgrounded — see Lifecycle.
  • Measure in profile mode. Debug builds are not representative; use flutter run --profile and the frame chart in DevTools and performance.

Next: Navigation and routing.