Flutter Animation Deep Dive
Master implicit transitions, explicit controllers, Hero flights, staggered sequences, and build production-quality animated interfaces.
Animation Fundamentals
Flutter provides two broad categories of animations. Understanding the difference between them is the first step toward building fluid, responsive user interfaces that delight users without sacrificing maintainability.
Implicit vs Explicit Animations
Implicit animations are the simplest way to add motion. You set a target value and Flutter automatically tweens between the old and new value. No controllers, no listeners — just declare what you want and the framework handles the interpolation. Widgets like AnimatedContainer, AnimatedOpacity, AnimatedPadding, and AnimatedPositioned all fall into this category. They are perfect for responsive feedback such as color changes, size transitions, and opacity fades.
Explicit animations give you full control over the animation lifecycle. You create an AnimationController, define a Tween, and listen for value changes to rebuild custom widget trees. This approach is essential for complex sequences, physics-based animations, and any scenario where you need programmatic control over playback direction, speed, and repetition.
| Feature | Implicit Animations | Explicit Animations |
|---|---|---|
| Setup Complexity | Low — just set target values | High — controllers, listeners, tweens |
| Control | Automatic on property change | Full control: forward, reverse, repeat |
| Use Cases | Simple transitions (size, color, opacity) | Complex sequences, physics, gestures |
| Performance | Optimized by the framework | Developer manages disposal |
| Examples | AnimatedContainer, AnimatedOpacity | AnimationController, TweenAnimationBuilder |
Core Concepts
Every Flutter animation is built on a few foundational abstractions:
- Animation<double> — An object that outputs a value over time. It does not know what widget it drives; it is purely a value stream.
- Tween<T> — A "between" object that maps a
doubleinput (typically 0.0–1.0) to a value of typeT. For example,Tween<Color>interpolates between two colors. - AnimationController — A special
Animationthat can be told to go forward, reverse, or stop. It uses aTickerunder the hood to fire callbacks on each frame. - CurvedAnimation — Wraps a controller to apply an easing curve (e.g., ease-in-out, bounce, elastic) to the raw 0.0–1.0 progress.
AnimatedContainer & Implicit Animations
Implicit animation widgets are StatefulWidgets that detect when a property changes and smoothly animate from the old value to the new value. The most versatile of these is AnimatedContainer, which can animate size, margin, padding, color, decoration, and alignment simultaneously.
class AnimatedBoxDemo extends StatefulWidget {
const AnimatedBoxDemo({super.key});
@override
State<AnimatedBoxDemo> createState() => _AnimatedBoxDemoState();
}
class _AnimatedBoxDemoState extends State<AnimatedBoxDemo> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: 400),
curve: Curves.easeInOut,
width: _expanded ? 200 : 100,
height: _expanded ? 200 : 100,
decoration: BoxDecoration(
color: _expanded ? Colors.blue : Colors.orange,
borderRadius: BorderRadius.circular(
_expanded ? 32 : 16,
),
boxShadow: [
BoxShadow(
color: Colors.black26,
blurRadius: _expanded ? 24 : 8,
offset: const Offset(0, 4),
),
],
),
child: const Center(
child: Text('Tap me',
style: TextStyle(color: Colors.white, fontSize: 18)),
),
),
);
}
}
Every implicit animation widget accepts a duration and an optional curve. When the property values change between rebuilds, Flutter calculates the intermediate values on each frame using the specified curve. This means you never write a setState that manually interpolates — you simply toggle a boolean or change a value, and the animation happens automatically.
Other Useful Implicit Animation Widgets
| Widget | Animates | Best For |
|---|---|---|
AnimatedOpacity | Opacity | Fading elements in/out |
AnimatedPadding | EdgeInsets | Shifting content with padding |
AnimatedPositioned | Position within Stack | Sliding overlays, tooltips |
AnimatedCrossFade | Cross-fading two children | Switching between two views |
AnimatedSwitcher | Cross-fading any child | Transitioning between different widgets |
AnimatedDefaultTextStyle | TextStyle properties | Dynamic text styling |
AnimatedBuilder & Custom Animations
When implicit animations are not enough, AnimatedBuilder (also known as AnimatedWidget) lets you listen to an AnimationController and rebuild only a specific part of the widget tree. This is more efficient than calling setState on the entire screen because it avoids unnecessary rebuilds of sibling widgets.
class SpinningIcon extends StatefulWidget {
final IconData icon;
const SpinningIcon({super.key, required this.icon});
@override
State<SpinningIcon> createState() => _SpinningIconState();
}
class _SpinningIconState extends State<SpinningIcon>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: child,
);
},
child: Icon(widget.icon, size: 64),
);
}
}
Notice how the child parameter is passed outside the builder callback. This is an optimization: the Icon widget is created once and reused on every frame, rather than being rebuilt. Only the Transform.rotate wrapper changes. This pattern is critical for smooth 60fps animations.
Building a Custom Animated Widget
You can create your own reusable animated widgets by extending AnimatedWidget. This removes the need for AnimatedBuilder and makes the animation self-contained:
class PulseAvatar extends AnimatedWidget {
final String imageUrl;
final double minScale;
final double maxScale;
const PulseAvatar({
required Animation<double> animation,
required this.imageUrl,
this.minScale = 0.9,
this.maxScale = 1.1,
}) : super(listenable: animation);
double get _scale =>
minScale + (maxScale - minScale) * (listenable as Animation<double>).value;
@override
Widget build(BuildContext context) {
return Transform.scale(
scale: _scale,
child: CircleAvatar(
backgroundImage: NetworkImage(imageUrl),
radius: 40,
),
);
}
}
AnimationController & Tickers
The AnimationController is the engine behind explicit animations. It produces a value between 0.0 and 1.0 (by default) on every frame, driving whatever Tween and CurvedAnimation you attach to it. Every controller requires a TickerProvider, which is typically provided by the SingleTickerProviderStateMixin mixin on your State class.
Creating and Using a Controller
class BounceButton extends StatefulWidget {
final VoidCallback onPressed;
final Widget child;
const BounceButton({
super.key,
required this.onPressed,
required this.child,
});
@override
State<BounceButton> createState() => _BounceButtonState();
}
class _BounceButtonState extends State<BounceButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 150),
reverseDuration: const Duration(milliseconds: 300),
);
_scaleAnim = Tween<double>(begin: 1.0, end: 0.9)
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onTapDown(TapDownDetails _) => _controller.forward();
void _onTapUp(TapUpDetails _) {
_controller.reverse();
widget.onPressed();
}
void _onTapCancel() => _controller.reverse();
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _onTapDown,
onTapUp: _onTapUp,
onTapCancel: _onTapCancel,
child: AnimatedBuilder(
animation: _scaleAnim,
builder: (context, child) =>
Transform.scale(scale: _scaleAnim.value, child: child),
child: widget.child,
),
);
}
}
Key Controller Methods
| Method | Description |
|---|---|
forward() | Animates from current value to upperBound (default 1.0) |
reverse() | Animates from current value to lowerBound (default 0.0) |
repeat() | Loops the animation (optionally in reverse) |
stop() | Halts the animation at the current value |
reset() | Sets value back to lowerBound without animating |
animateTo(target) | Animates to a specific value |
fling() | Starts a physics-based animation (spring) |
TickerProviderStateMixin
The vsync parameter of AnimationController must receive a TickerProvider. In practice, you mix SingleTickerProviderStateMixin into your State class for one controller, or TickerProviderStateMixin if you need multiple controllers. The ticker synchronizes animation frames with the device display refresh rate, preventing wasted work when the app is in the background.
AnimationController in the dispose() method. Failing to do so leaks the ticker and causes "A Ticker was used after being disposed" errors at runtime.
Hero Animations
Hero animations create a seamless visual transition of a widget from one screen to another during navigation. Flutter handles the interpolation automatically: you wrap matching widgets in Hero widgets with the same tag, and the framework animates the position and size between the two routes.
// Screen A — list item with a thumbnail
Navigator.push(context, MaterialPageRoute(
builder: (_) => DetailScreen(item: item),
));
// In the list item widget:
Hero(
tag: 'item-${item.id}',
child: CircleAvatar(
backgroundImage: NetworkImage(item.avatarUrl),
),
)
// Screen B — detail view with the same Hero
Hero(
tag: 'item-${item.id}',
child: CircleAvatar(
radius: 60,
backgroundImage: NetworkImage(item.avatarUrl),
),
)
The tag must be unique across the navigation stack for each Hero pair. Flutter matches heroes by tag when a route transition begins and calculates the interpolated position, size, and shape. You can customize the transition with flightShuttleBuilder for advanced effects like parallax or rotation during the flight.
MaterialPageRoute and CupertinoPageRoute. For custom page transitions, wrap your route in a HeroController or use the PageRouteBuilder with hero-enabled transitions.
Staggered Animations
Staggered animations play multiple animations in sequence with overlapping time windows. A common pattern is a list of items that slide in one after another, creating a cascading entrance effect. This is achieved by assigning each item a time interval within a single parent AnimationController.
class StaggeredList extends StatefulWidget {
final List<String> items;
const StaggeredList({super.key, required this.items});
@override
State<StaggeredList> createState() => _StaggeredListState();
}
class _StaggeredListState extends State<StaggeredList>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Interval _itemInterval(int index) {
final count = widget.items.length;
final itemDuration = 1.0 / (count + 2);
final start = index * itemDuration;
return Interval(start, start + itemDuration, curve: Curves.easeOut);
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: widget.items.length,
itemBuilder: (context, index) {
final anim = CurvedAnimation(
parent: _controller,
curve: _itemInterval(index),
);
return FadeTransition(
opacity: anim,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(anim),
child: ListTile(title: Text(widget.items[index])),
),
);
},
);
}
}
The key insight is the Interval class. It maps a sub-range of the controller's 0.0–1.0 timeline to each item's animation. By overlapping the intervals slightly, you create a smooth cascading effect. This technique is far more performant than creating individual controllers for each item because a single ticker drives all animations.
flutter_staggered_animations package which provides ready-made widgets like AnimationLimiter and StaggeredTile to reduce boilerplate for common staggered patterns.
Real-World Example: Animated Login Screen
This example combines everything you've learned into a production-quality animated login screen. It features a logo bounce-in on load, text fields that slide in from the bottom with staggered timing, and a pulsing login button that draws user attention. All animations use a single AnimationController for efficiency.
import 'package:flutter/material.dart';
class AnimatedLoginScreen extends StatefulWidget {
const AnimatedLoginScreen({super.key});
@override
State<AnimatedLoginScreen> createState() => _AnimatedLoginScreenState();
}
class _AnimatedLoginScreenState extends State<AnimatedLoginScreen>
with TickerProviderStateMixin {
late AnimationController _entranceController;
late AnimationController _pulseController;
late Animation<double> _logoScale;
late Animation<double> _emailSlide;
late Animation<double> _passwordSlide;
late Animation<double> _buttonSlide;
late Animation<double> _pulseScale;
@override
void initState() {
super.initState();
// Entrance animation: logo bounce then fields slide in
_entranceController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
);
_logoScale = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.0, 0.4, curve: Curves.elasticOut),
),
);
_emailSlide = Tween<double>(begin: 50.0, end: 0.0).animate(
CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.3, 0.6, curve: Curves.easeOut),
),
);
_passwordSlide = Tween<double>(begin: 50.0, end: 0.0).animate(
CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.45, 0.75, curve: Curves.easeOut),
),
);
_buttonSlide = Tween<double>(begin: 50.0, end: 0.0).animate(
CurvedAnimation(
parent: _entranceController,
curve: const Interval(0.6, 0.9, curve: Curves.easeOut),
),
);
// Continuous pulse for the login button
_pulseController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1000),
);
_pulseScale = Tween<double>(begin: 1.0, end: 1.05).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
// Start the entrance, then loop the pulse
_entranceController.forward().then((_) {
_pulseController.repeat(reverse: true);
});
}
@override
void dispose() {
_entranceController.dispose();
_pulseController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo with bounce-in
AnimatedBuilder(
animation: _logoScale,
builder: (context, child) => Transform.scale(
scale: _logoScale.value,
child: child,
),
child: const Icon(
Icons.lock_outline,
size: 80,
color: Colors.blue,
),
),
const SizedBox(height: 48),
// Email field slide-in
AnimatedBuilder(
animation: _emailSlide,
builder: (context, child) => Transform.translate(
offset: Offset(0, _emailSlide.value),
child: child,
),
child: TextField(
decoration: InputDecoration(
labelText: 'Email',
prefixIcon: const Icon(Icons.email_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 16),
// Password field slide-in
AnimatedBuilder(
animation: _passwordSlide,
builder: (context, child) => Transform.translate(
offset: Offset(0, _passwordSlide.value),
child: child,
),
child: TextField(
obscureText: true,
decoration: InputDecoration(
labelText: 'Password',
prefixIcon: const Icon(Icons.lock_outlined),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 24),
// Pulsing login button
AnimatedBuilder(
animation: Listenable.merge([_buttonSlide, _pulseScale]),
builder: (context, child) => Transform.translate(
offset: Offset(0, _buttonSlide.value),
child: Transform.scale(
scale: _pulseScale.value,
child: child,
),
),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: () {},
style: ElevatedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: const Text('Log In', style: TextStyle(fontSize: 18)),
),
),
),
],
),
),
),
);
}
}
How It Works
The login screen uses two separate AnimationControllers for good reason. The entrance controller drives a one-shot sequence: the logo bounces in first using an elastic curve, then the email field slides up, followed by the password field, and finally the button. The Interval class maps each animation to a specific time window within the controller's duration.
Once the entrance completes, the pulse controller takes over and continuously scales the button between 1.0 and 1.05, creating a subtle breathing effect that signals interactivity. Using TickerProviderStateMixin (instead of SingleTickerProviderStateMixin) allows both controllers to share the same vsync provider.
The Listenable.merge combines the button slide and pulse scale so that a single AnimatedBuilder can apply both transforms. This avoids nesting multiple builder widgets and keeps the widget tree clean.
Performance Tips
Animations are one of the most performance-sensitive parts of a Flutter app. A dropped frame during a transition is immediately noticeable and degrades the user experience. Follow these guidelines to keep your animations buttery smooth at 60fps (or 120fps on ProMotion displays).
1. Minimize Rebuild Scope
Use AnimatedBuilder or AnimatedWidget instead of calling setState on the parent. setState rebuilds the entire subtree, while AnimatedBuilder only rebuilds the portion wrapped in its builder callback. In the spinning icon example above, only the Transform.rotate widget rebuilds on each frame — the Icon stays the same thanks to the child parameter optimization.
2. Use const Widgets
Mark static parts of your animation widgets as const. Flutter's widget diffing algorithm can skip const widgets entirely during rebuilds, reducing the work the framework must do on each frame.
3. Prefer Opacity and Transform
Opacity and Transform are GPU-accelerated and do not trigger layout recalculation. Avoid animating properties that cause full layout passes like padding, margin, or constraints when possible. If you must animate size, use AnimatedContainer which batches the layout changes efficiently.
4. Dispose Controllers
Always dispose AnimationControllers in dispose(). Undisposed controllers keep ticking, consuming CPU and leaking memory. This is the most common source of performance degradation in animated Flutter apps.
5. Avoid Heavy Work in the Animation Listener
If you attach a listener to an AnimationController, keep the callback lightweight. Do not perform network calls, file I/O, or complex calculations inside the listener. The listener fires on every frame — anything expensive will cause jank.
6. Use RepaintBoundary
Wrap complex animated widgets in RepaintBoundary to isolate their repaint region. Without it, a changing animation can cause the entire parent to repaint. With RepaintBoundary, only the animated region is repainted.
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return CustomPaint(
painter: AnimatedParticlePainter(_controller.value),
child: child,
);
},
child: const SizedBox(width: 300, height: 300),
),
)
7. Profile with Flutter DevTools
Use the Flutter DevTools Performance tab to identify dropped frames during your animations. The frame timeline shows exactly which builds and paints are taking too long. Look for frames exceeding the 16ms budget (8ms on 120Hz displays) and optimize those specific widgets.
Timer.periodic to drive animations. It runs on the Dart event loop, not the vsync, and will produce visibly choppy motion. Always use AnimationController which is synchronized to the display refresh rate via Ticker.
Summary
- Implicit animations (AnimatedContainer, AnimatedOpacity) are ideal for simple state-driven transitions with minimal code.
- Explicit animations (AnimationController, Tween, CurvedAnimation) give full control over playback, direction, and sequencing.
- AnimatedBuilder isolates rebuilds to only the animated portion of the widget tree for optimal performance.
- Hero animations create seamless cross-route transitions using matching tags.
- Staggered animations use
Intervalto sequence multiple animations within a single controller. - Always dispose your AnimationControllers to prevent memory leaks and ticker errors.
- Use RepaintBoundary and const widgets to keep frame times under budget.
| Technique | When to Use | Complexity |
|---|---|---|
| AnimatedContainer | Property changes (size, color, padding) | Low |
| AnimatedBuilder | Custom visual on each frame | Medium |
| AnimationController | Gesture-driven, looping, or sequenced | Medium-High |
| Hero | Cross-screen widget transitions | Low |
| Staggered + Interval | Cascading list entrance effects | Medium |
| Physics-based (Spring) | Natural, momentum-driven motion | High |