Flutter Animation System: Implicit, Explicit, and Custom Animations
# Flutter Animation System Deep Dive
Animations are what separate a functional app from one that feels alive. Flutter ships with one of the most powerful animation systems in mobile development, but its layered architecture can be overwhelming if you don't know where to start. After building apps where nearly every screen involved choreographed motion, I've developed a clear mental model for choosing the right animation approach every time.
The Animation Layer Cake
Flutter's animation system is built in layers. At the bottom sits the `Ticker` that fires on every vsync (usually 60 or 120 times per second). On top of that, `AnimationController` manages timing and value interpolation. Above that, `Tween` objects map ranges to concrete values. And at the very top, implicit animation widgets wrap all of that complexity into a single line of code.
Understanding these layers means you always know where to reach when the convenient option falls short.
Implicit Animations: The Easy Win
Implicit animations are Flutter's gift to developers who want motion without ceremony. You describe the target state, and the framework handles the transition.
AnimatedContainer
The workhorse of implicit animations. It interpolates between any change to its decoration, size, padding, margin, alignment, or transform.
class ExpandableCard extends StatefulWidget {
const ExpandableCard({super.key});
@override
State<ExpandableCard> createState() => _ExpandableCardState();
}
class _ExpandableCardState extends State<ExpandableCard> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: AnimatedContainer(
duration: const Duration(milliseconds: class="code-number">400),
curve: Curves.easeInOutCubic,
width: _expanded ? class="code-number">300 : class="code-number">150,
height: _expanded ? class="code-number">200 : class="code-number">80,
decoration: BoxDecoration(
color: _expanded ? Colors.indigo : Colors.indigo.shade200,
borderRadius: BorderRadius.circular(_expanded ? class="code-number">24 : class="code-number">12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(_expanded ? class="code-number">0.3 : class="code-number">0.1),
blurRadius: _expanded ? class="code-number">20 : class="code-number">8,
offset: Offset(class="code-number">0, _expanded ? class="code-number">10 : class="code-number">4),
),
],
),
child: Center(
child: Text(
_expanded ? class="code-string">'Expanded' : class="code-string">'Tap me',
style: const TextStyle(color: Colors.white, fontSize: class="code-number">18),
),
),
),
);
}
}Other Implicit Widgets Worth Knowing
Each of these follows the same pattern: change a property, and the widget animates to it.
class=class="code-string">"code-comment">// Fade a widget in and out
AnimatedOpacity(
opacity: _visible ? class="code-number">1.0 : class="code-number">0.0,
duration: const Duration(milliseconds: class="code-number">300),
child: const Text(class="code-string">'Now you see me'),
)
class=class="code-string">"code-comment">// Slide a widget to a new position
AnimatedPositioned(
duration: const Duration(milliseconds: class="code-number">500),
curve: Curves.elasticOut,
left: _moved ? class="code-number">200 : class="code-number">50,
top: _moved ? class="code-number">100 : class="code-number">50,
child: const CircleAvatar(radius: class="code-number">30),
)
class=class="code-string">"code-comment">// Smoothly switch between two widgets
AnimatedSwitcher(
duration: const Duration(milliseconds: class="code-number">400),
transitionBuilder: (child, animation) => ScaleTransition(
scale: animation,
child: child,
),
child: Text(
class="code-string">'$_count',
key: ValueKey<int>(_count),
style: const TextStyle(fontSize: class="code-number">48),
),
)In apps with heavy animations, I've learned that implicit animations should be your first instinct. About 70% of the motion you need in a typical app can be achieved without ever touching an `AnimationController`.
Explicit Animations: Full Control
When you need precise timing, sequencing, or the ability to play, pause, reverse, or repeat an animation, you step into explicit animation territory.
The Core Trio: AnimationController, Tween, CurvedAnimation
class PulsingButton extends StatefulWidget {
const PulsingButton({super.key});
@override
State<PulsingButton> createState() => _PulsingButtonState();
}
class _PulsingButtonState extends State<PulsingButton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scaleAnimation;
late final Animation<Color?> _colorAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: class="code-number">1200),
vsync: this,
)..repeat(reverse: true);
final curvedAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
_scaleAnimation = Tween<double>(begin: class="code-number">1.0, end: class="code-number">1.15).animate(curvedAnimation);
_colorAnimation = ColorTween(
begin: Colors.blue,
end: Colors.purple,
).animate(curvedAnimation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: _colorAnimation.value,
padding: const EdgeInsets.symmetric(horizontal: class="code-number">32, vertical: class="code-number">16),
),
onPressed: () {},
child: child,
),
);
},
child: const Text(class="code-string">'Subscribe', style: TextStyle(fontSize: class="code-number">18)),
);
}
}Key points to remember: always use `SingleTickerProviderStateMixin` (or `TickerProviderStateMixin` for multiple controllers), always dispose your controllers, and always pass a static `child` to `AnimatedBuilder` when possible to avoid rebuilding the subtree on every frame.
TweenAnimationBuilder: The Middle Ground
When you want Tween-based control without managing an `AnimationController`, `TweenAnimationBuilder` is your friend.
TweenAnimationBuilder<double>(
tween: Tween(begin: class="code-number">0.0, end: _targetAngle),
duration: const Duration(milliseconds: class="code-number">800),
curve: Curves.easeOutBack,
builder: (context, angle, child) {
return Transform.rotate(
angle: angle,
child: child,
);
},
child: const Icon(Icons.refresh, size: class="code-number">48),
)Staggered Animations: Choreographed Motion
Staggered animations use a single `AnimationController` with multiple `Interval`-based curves. Each element starts at a different point in the timeline, creating a cascading effect.
class StaggeredListAnimation extends StatefulWidget {
const StaggeredListAnimation({super.key});
@override
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
final int _itemCount = class="code-number">5;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: class="code-number">1500),
vsync: this,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Animation<double> _buildSlideAnimation(int index) {
final start = index * class="code-number">0.1;
final end = start + class="code-number">0.4;
return Tween<double>(begin: class="code-number">80.0, end: class="code-number">0.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(class="code-number">0.0, class="code-number">1.0), curve: Curves.easeOutCubic),
),
);
}
Animation<double> _buildFadeAnimation(int index) {
final start = index * class="code-number">0.1;
final end = start + class="code-number">0.4;
return Tween<double>(begin: class="code-number">0.0, end: class="code-number">1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end.clamp(class="code-number">0.0, class="code-number">1.0), curve: Curves.easeOut),
),
);
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Column(
children: List.generate(_itemCount, (index) {
final slide = _buildSlideAnimation(index);
final fade = _buildFadeAnimation(index);
return Opacity(
opacity: fade.value,
child: Transform.translate(
offset: Offset(slide.value, class="code-number">0),
child: ListTile(
leading: CircleAvatar(child: Text(class="code-string">'${index + class="code-number">1}')),
title: Text(class="code-string">'Item ${index + class="code-number">1}'),
),
),
);
}),
);
},
);
}
}The trick is to keep all intervals within the 0.0 to 1.0 range and overlap them slightly so the motion feels continuous rather than sequential.
Custom Animations with CustomPainter
When widgets can't express what you need, drop down to the canvas. `CustomPainter` lets you draw anything and animate it frame by frame.
class WaveAnimation extends StatefulWidget {
const WaveAnimation({super.key});
@override
State<WaveAnimation> createState() => _WaveAnimationState();
}
class _WaveAnimationState extends State<WaveAnimation>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: class="code-number">3),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return CustomPaint(
size: const Size(double.infinity, class="code-number">200),
painter: WavePainter(progress: _controller.value),
);
},
);
}
}
class WavePainter extends CustomPainter {
final double progress;
WavePainter({required this.progress});
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.blue.withOpacity(class="code-number">0.6)
..style = PaintingStyle.fill;
final path = Path()..moveTo(class="code-number">0, size.height * class="code-number">0.5);
for (double x = class="code-number">0; x <= size.width; x++) {
final y = size.height * class="code-number">0.5 +
sin((x / size.width * class="code-number">2 * pi) + (progress * class="code-number">2 * pi)) * class="code-number">30 +
sin((x / size.width * class="code-number">4 * pi) + (progress * class="code-number">4 * pi)) * class="code-number">15;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.lineTo(class="code-number">0, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.progress != progress;
}
}The `shouldRepaint` method is critical. Return `true` only when the data your painter depends on has actually changed. Returning `true` unconditionally forces a repaint every frame even when nothing moved, which wastes GPU time.
Hero Animations and Page Transitions
Hero animations create visual continuity between screens by morphing a shared element during navigation.
class=class="code-string">"code-comment">// Source screen
GestureDetector(
onTap: () => Navigator.push(
context,
MaterialPageRoute(builder: (_) => DetailScreen(item: item)),
),
child: Hero(
tag: class="code-string">'item-${item.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(class="code-number">12),
child: Image.network(item.imageUrl, width: class="code-number">100, height: class="code-number">100, fit: BoxFit.cover),
),
),
)
class=class="code-string">"code-comment">// Destination screen
Hero(
tag: class="code-string">'item-${item.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(class="code-number">0),
child: Image.network(item.imageUrl, width: double.infinity, height: class="code-number">300, fit: BoxFit.cover),
),
)Custom Page Transitions
For more control over how pages enter and exit, build your own `PageRouteBuilder`.
Route createFadeScaleRoute(Widget page) {
return PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: class="code-number">500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeInOutCubic);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween<double>(begin: class="code-number">0.92, end: class="code-number">1.0).animate(curved),
child: child,
),
);
},
);
}
class=class="code-string">"code-comment">// Usage
Navigator.push(context, createFadeScaleRoute(const DetailScreen()));Animation Performance Tips
1. Use RepaintBoundary
When only part of the screen is animating, wrap it in a `RepaintBoundary`. This tells the engine to composite that subtree on its own layer, so the rest of the UI is not repainted.
RepaintBoundary(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * class="code-number">2 * pi,
child: child,
);
},
child: const Icon(Icons.sync, size: class="code-number">64),
),
)2. Implement shouldRepaint Correctly
In `CustomPainter`, an incorrect `shouldRepaint` is a silent performance killer. Compare only the fields your `paint` method uses.
@override
bool shouldRepaint(WavePainter oldDelegate) {
return oldDelegate.progress != progress;
class=class="code-string">"code-comment">// Do NOT always return true unless you know it changes every frame
}3. Prefer Transform Over Layout Changes
`Transform.translate`, `Transform.scale`, and `Transform.rotate` operate at the compositing layer. They do not trigger layout recalculations. By contrast, animating `width`, `height`, or `padding` forces a full layout pass.
4. Use the child Parameter in AnimatedBuilder
Anything that does not change during the animation should be passed as the `child` parameter. This subtree is built once and reused on every frame.
5. Avoid Opacity When Possible
The `Opacity` widget creates an offscreen buffer. For simple fade-ins, prefer `FadeTransition` with an explicit animation, or use `AnimatedOpacity` which internally optimizes this.
When to Use Which Animation Type
| Scenario | Recommended Approach |
|---|---|
| Property change on tap (color, size, border) | `AnimatedContainer` or other implicit widget |
| Fade or slide a widget in/out | `AnimatedOpacity`, `AnimatedSlide` |
| Switching between two widgets | `AnimatedSwitcher` |
| Looping or repeating animation | Explicit with `AnimationController.repeat()` |
| Precise play/pause/reverse control | Explicit with `AnimationController` |
| Multiple elements entering in sequence | Staggered animation with `Interval` |
| Drawing shapes, charts, particles | `CustomPainter` with `AnimationController` |
| Shared element between screens | `Hero` widget |
| Custom page enter/exit | `PageRouteBuilder` |
| Simple tween without controller boilerplate | `TweenAnimationBuilder` |
If you are unsure, start with an implicit animation. You can always upgrade to explicit control later without restructuring your widget.
Common Mistakes and Gotchas
1. Forgetting to Dispose Controllers
Every `AnimationController` must be disposed in `dispose()`. A leaked controller keeps its `Ticker` alive, which fires callbacks on a widget that no longer exists, leading to the dreaded "setState called after dispose" error.
2. Using the Wrong Mixin
`SingleTickerProviderStateMixin` supports one controller. If you need two or more, switch to `TickerProviderStateMixin`. Using the single version with multiple controllers causes a runtime error that is not immediately obvious.
3. Rebuilding the Entire Subtree
Wrapping your whole `Scaffold` in `AnimatedBuilder` means the entire screen rebuilds 60 times per second. Isolate the animated portion as tightly as possible.
4. Animating Layout Properties in Lists
Animating the height of items inside a `ListView` triggers layout for every visible item on every frame. Use `SizeTransition` or `AnimatedList` instead, which handle this efficiently.
5. Ignoring Reduced Motion Preferences
Some users have system-level reduced motion settings enabled. Respect them.
final reduceMotion = MediaQuery.of(context).disableAnimations;
final duration = reduceMotion
? Duration.zero
: const Duration(milliseconds: class="code-number">400);6. Hardcoding Duration Everywhere
Define animation durations as constants in a central location. When you need to adjust the feel of your app, you change one value instead of hunting through dozens of files.
abstract class AppAnimations {
static const fast = Duration(milliseconds: class="code-number">200);
static const medium = Duration(milliseconds: class="code-number">400);
static const slow = Duration(milliseconds: class="code-number">800);
static const pageTransition = Duration(milliseconds: class="code-number">500);
}Personal Insights
In apps with heavy animations, I've learned that the biggest productivity gain comes from creating a small library of reusable animation wrappers. A `FadeSlideIn` widget that handles the controller lifecycle and accepts `delay`, `duration`, and `offset` parameters will save you from writing the same `SingleTickerProviderStateMixin` boilerplate dozens of times.
I've also learned the hard way that animation performance issues almost never show up on high-end development devices. Always test on the lowest-spec device your users might have. An animation that runs at 60fps on your Pixel 8 might stutter at 30fps on a budget phone, and `RepaintBoundary` placement becomes critical at that point.
One last thing: curves matter more than duration. A 400ms animation with `Curves.easeOutCubic` feels snappier than a 200ms animation with `Curves.linear`. Spend time experimenting with curves before reaching for shorter durations.
Conclusion
Flutter's animation system is remarkably flexible once you understand the layers. Start with implicit animations for quick wins, reach for explicit control when you need timing precision, and drop down to `CustomPainter` when the widget system cannot express your vision. Pair all of it with disciplined performance practices, and your apps will feel as responsive as they are beautiful.
If you need help designing a polished animation system for your Flutter app, get in touch.
Related Articles
What is Flutter? A Complete Beginner's Guide
Learn what Flutter is, how it works, and why modern product teams choose it. Discover Dart, widget architecture, and practical multi-platform development.
Flutter Performance Optimization: Complete Guide
Improve your Flutter app performance systematically. Learn rebuild optimization, memory management, lazy loading, and profiling techniques.
Flutter Widget Lifecycle: From initState to dispose
Understand the StatefulWidget lifecycle step by step and avoid common lifecycle mistakes.
Have a Flutter Project?
I build high-performance Flutter applications for iOS, Android, and web.
Get in Touch