Animations & Motion
Flutter animation engineering — implicit vs explicit, AnimationController lifecycle, Hero, physics, performance, reduce-motion
Animations & Motion
A practical guide to motion in Flutter. The guiding principle: animate the cheapest property on the smallest subtree, dispose every controller, and respect the user's reduce-motion setting.
For RenderObject-level custom drawing that animation can drive, see Advanced Usage; for the render pipeline and rebuild-cost model, see Performance.
Implicit vs Explicit
Flutter offers two animation families. Choosing the wrong one is the most common source of either bloated code or janky motion.
| Dimension | Implicit | Explicit |
|---|---|---|
| Trigger | Set a new value, Flutter animates the delta | You drive a controller manually |
| Control | None — fire and forget | Full: play, reverse, repeat, stop, seek |
| Boilerplate | Minimal (one Widget) | Controller + Tween + dispose |
| State needed | Often none | StatefulWidget + vsync |
| Best for | One-shot transitions between two states | Looping, staggered, gesture-driven, coordinated |
| Examples | AnimatedContainer, AnimatedOpacity | AnimationController + AnimatedBuilder |
Rule of thumb: reach for an implicit Animated* Widget first. Drop to an explicit AnimationController only when you need to control timing — repeat, reverse on demand, coordinate multiple animations, or drive from a gesture.
Implicit Animations
Every implicit animation follows the same model: a duration, an optional curve, and a target value. When the value changes between builds, Flutter interpolates from old to new.
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 200 : 100,
height: _expanded ? 200 : 100,
color: _expanded ? Colors.blue : Colors.grey,
// setState(() => _expanded = !_expanded) triggers the animation
)The whole family shares this contract:
| Widget | Animates |
|---|---|
AnimatedContainer | size, color, padding, alignment, decoration |
AnimatedOpacity | opacity (cheap — compositor-only) |
AnimatedPositioned | position inside a Stack |
AnimatedAlign | alignment |
AnimatedPadding | padding |
AnimatedDefaultTextStyle | text style |
AnimatedSwitcher | swaps child with a transition (needs a Key) |
TweenAnimationBuilder
When you want implicit behavior for a property no Animated* Widget covers, TweenAnimationBuilder animates any Tween toward a new end without a controller:
TweenAnimationBuilder<double>(
tween: Tween(begin: 0, end: progress),
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
builder: (_, value, child) => LinearProgressIndicator(value: value),
)Each time end changes, it animates from the current value. Pass a static subtree as child to keep it out of the rebuild (see Rebuild Scoping).
Explicit Animations
When you need control, the building blocks are AnimationController, Animation<T>, Tween, and CurvedAnimation.
AnimationController Lifecycle
The controller produces values from 0.0 to 1.0 over its duration, ticking once per frame. It needs a vsync (a TickerProvider) so off-screen animations are paused automatically.
class _PulseState extends State<Pulse> with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 1),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose(); // MANDATORY — leaks a Ticker otherwise
super.dispose();
}
@override
Widget build(BuildContext context) => FadeTransition(
opacity: _controller,
child: const Icon(Icons.favorite),
);
}- Use
SingleTickerProviderStateMixinfor one controller,TickerProviderStateMixinfor several. - Drive playback with
forward(),reverse(),repeat(),stop(),reset(), or jump withvalue/animateTo(). dispose()is non-negotiable — a leakedTickerkeeps firing and is flagged by the framework in debug mode.
Never create an AnimationController inside build. It must live in State and be created once (initState or a late final field). Creating one per build leaks controllers every frame and never animates.
Tween, CurvedAnimation, and .drive()
The controller emits 0..1. A Tween maps that range onto your target type; a CurvedAnimation reshapes the timing.
late final Animation<double> _size = _controller.drive(
Tween(begin: 0.0, end: 300.0).chain(
CurveTween(curve: Curves.elasticOut),
),
);
// Equivalent, more explicit form:
late final Animation<Color?> _color = ColorTween(
begin: Colors.red,
end: Colors.blue,
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
);Tween works for any lerp-able type: Tween<double>, ColorTween, AlignmentTween, EdgeInsetsTween, Size, Offset, even TweenSequence for multi-stage paths.
Rebuild Scoping
An animation that runs setState (or rebuilds a large builder) every frame is the single biggest perf trap. Confine rebuilds to the animating leaf with AnimatedBuilder/AnimatedWidget, and hoist anything static into the child parameter.
AnimatedBuilder(
animation: _controller,
child: const ExpensiveSubtree(), // built ONCE, passed through untouched
builder: (_, child) => Transform.rotate(
angle: _controller.value * 2 * pi,
child: child, // reused every frame
),
)The builder runs each frame but only rebuilds the Transform; ExpensiveSubtree is constructed once. The same child-passthrough applies to TweenAnimationBuilder and the built-in *Transition Widgets (FadeTransition, ScaleTransition, SlideTransition) — prefer these over AnimatedBuilder when one exists, since they are already scoped optimally.
AnimatedWidget is the class-based equivalent: subclass it, read the animation in build, and the framework subscribes for you.
Staggered Animations
To choreograph several animations from one controller, give each its own Interval curve carving out a slice of the 0..1 timeline.
class _StaggerState extends State<Stagger> with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
late final Animation<double> _opacity = CurvedAnimation(
parent: _c,
curve: const Interval(0.0, 0.4, curve: Curves.easeIn),
);
late final Animation<double> _width = Tween(begin: 50.0, end: 200.0).animate(
CurvedAnimation(parent: _c, curve: const Interval(0.4, 0.7)),
);
late final Animation<double> _height = Tween(begin: 50.0, end: 200.0).animate(
CurvedAnimation(parent: _c, curve: const Interval(0.7, 1.0)),
);
// opacity fades 0–40%, width grows 40–70%, height grows 70–100%
}One controller keeps the stages perfectly in sync and is cheaper than juggling several controllers.
Hero & Shared-Element Transitions
A Hero flies a Widget from one route to the next when both pages contain a Hero with the same tag.
// Source page
Hero(tag: 'product-$id', child: Image.network(thumbUrl))
// Destination page
Hero(tag: 'product-$id', child: Image.network(fullUrl))flightShuttleBuilder
By default the source Widget is reparented mid-flight. When source and destination differ structurally (e.g. an icon morphing into a title), supply a flightShuttleBuilder to render the in-between:
Hero(
tag: 'product-$id',
flightShuttleBuilder: (_, animation, __, ___, ____) => FadeTransition(
opacity: animation,
child: Material(color: Colors.transparent, child: ...),
),
child: ...,
)Common Hero pitfalls. Tags must be unique per page — two Heroes sharing a tag on one screen throws. Wrapping the flying child's text in plain Text without a Material ancestor produces yellow underline glitches mid-flight; wrap with Material(type: MaterialType.transparency). And a Hero inside a lazily-built list only animates if it is actually built on both routes.
Page Route Transitions
Customize how a whole page enters with PageRouteBuilder:
Navigator.push(context, PageRouteBuilder(
transitionDuration: const Duration(milliseconds: 300),
pageBuilder: (_, __, ___) => const DetailPage(),
transitionsBuilder: (_, animation, __, child) => SlideTransition(
position: Tween(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(CurvedAnimation(parent: animation, curve: Curves.easeOut)),
child: child,
),
))With declarative routing, wrap the same transitionsBuilder in go_router's CustomTransitionPage instead of pushing manually — see Routing in Best Practices.
Physics-Based Animations
For motion that should feel natural after a gesture — springing back, flinging, settling — drive the controller with a physics Simulation rather than a fixed duration.
void _onPanEnd(DragEndDetails details, Size size) {
final velocity = details.velocity.pixelsPerSecond.dx / size.width;
const spring = SpringDescription(mass: 1, stiffness: 500, damping: 25);
final simulation = SpringSimulation(spring, _controller.value, 0.0, velocity);
_controller.animateWith(simulation); // ignores `duration`, follows physics
}animateWith(simulation) runs until the simulation reports it is done. fling() is the shorthand for a spring from the current value driven by a velocity. This is how dismissible cards, bottom sheets, and swipe-to-reorder get their feel. For tuning scroll physics specifically, see Custom ScrollPhysics in Advanced Usage.
Animated Lists
AnimatedList (and SliverAnimatedList for CustomScrollView) animates insertion and removal. You mutate a backing list and call the matching method so the framework can run the transition:
final _key = GlobalKey<AnimatedListState>();
void _insert(int index, Item item) {
_items.insert(index, item);
_key.currentState!.insertItem(index, duration: const Duration(milliseconds: 300));
}
AnimatedList(
key: _key,
initialItemCount: _items.length,
itemBuilder: (_, i, animation) => SizeTransition(
sizeFactor: animation,
child: ListTile(title: Text(_items[i].name)),
),
)For removal, the removeItem builder receives the old item to animate out. For a high-level declarative API, flutter_animate adds chainable effects (.fadeIn().slideY()) and flutter_staggered_animations wraps list children with staggered entrances — both reduce boilerplate for common cases.
Driving a CustomPainter
A CustomPainter can repaint directly off a Listenable (such as an Animation) via the repaint: argument — no AnimatedBuilder needed, and no Widget rebuild per frame:
class WavePainter extends CustomPainter {
final Animation<double> animation;
WavePainter(this.animation) : super(repaint: animation); // repaint on tick
@override
void paint(Canvas canvas, Size size) {
// read animation.value, draw
}
@override
bool shouldRepaint(WavePainter old) => false; // repaint is driven by `repaint:`
}
CustomPaint(painter: WavePainter(_controller), size: const Size(300, 100))This is the most efficient path for canvas animation: only the paint phase runs each frame, skipping build and layout entirely. See Advanced Usage for the CustomPainter fundamentals.
Lottie and Rive
For designer-authored motion, embed exported assets rather than hand-coding:
lottie— plays After Effects animations (Bodymovin JSON). Best for non-interactive, fire-and-forget motion.rive— interactive, state-machine-driven animations editable in the Rive editor; lighter runtime and supports runtime inputs.
Lottie.asset('assets/success.json', repeat: false)
RiveAnimation.asset('assets/button.riv', stateMachines: ['State Machine 1'])Use these when motion is complex enough that authoring it in code would be unmaintainable.
Performance
Motion is where jank shows. Keep these rules in mind:
- Prefer transform and opacity over layout. Animating
width/height/paddingre-runs layout every frame; animatingTransform.scaleorFadeTransitiontouches only paint/composite. Re-express layout animations as transforms where possible. - Wrap the animated subtree in
RepaintBoundaryso its repaints do not dirty siblings — it gets its own compositor layer. - Keep the animated subtree small. Scope rebuilds with the
childparameter (see Rebuild Scoping); never animate near the root of a large tree. - Profile in the DevTools timeline. Run in profile mode, record the Performance view, and look for frames exceeding the 16 ms budget; the raster thread tells you whether the cost is in paint/composite.
- Do nothing expensive in listeners. A controller fires every frame — see Anti-Patterns.
For the full render-pipeline mental model and image-cache tuning, see Performance.
Accessibility & Reduce Motion
Some users enable "reduce motion" at the OS level (vestibular disorders, motion sensitivity). Honor it.
final reduceMotion = MediaQuery.of(context).disableAnimations;
// Skip or shorten non-essential motion
final duration = reduceMotion
? Duration.zero
: const Duration(milliseconds: 300);MediaQuery.disableAnimationsreflects the OS reduce-motion / remove-animations setting.MediaQuery.accessibleNavigationis true when a screen reader is active — avoid auto-advancing carousels and time-based transitions that a reader cannot keep up with.- Replace large parallax/zoom transitions with a simple cross-fade when reduce-motion is on; never remove the state change, only the motion.
See Accessibility for the broader Semantics and focus story.
Anti-Patterns
- Forgetting
dispose()on a controller. Leaks aTickerthat keeps firing forever; the framework warns in debug. Always pair creation inStatewith disposal. - Creating or animating inside
build. A controller (orTween.animatechain) built per frame never animates and leaks. Create once inState. - Heavy work in animation listeners. A controller fires ~60 times/second; parsing, network, or
setStateon a large tree insideaddListener/the builder tanks the frame rate. Keep per-frame work to a single cheap value read. - Over-using implicit animations on large trees. An
AnimatedContainerwrapping a whole page re-lays-out everything each frame. Scope motion to a small leaf. - Ignoring reduce-motion. Aggressive parallax and auto-play motion are an accessibility failure and can cause physical discomfort. Check
MediaQuery.disableAnimations. - Janky layout animations instead of transforms. Animating
width/height/marginforces relayout; useTransform/scale/opacitywhich stay on the compositor. - Hero tag collisions. Duplicate tags on one screen throw at runtime; derive tags from a stable unique id.