After 50 days of Flutter, you've probably noticed that animations are everywhere—buttons scale on tap, modals fade in, lists rearrange themselves. The question you're facing now isn't whether to animate, but how. Flutter gives you two fundamentally different approaches: implicit vs explicit animations. Understanding when to reach for each one will save you from over-engineering simple transitions and struggling with complex choreography.
Implicit animations — AnimatedContainer, AnimatedOpacity — change a value, Flutter animates.
Implicit animations are Flutter's way of saying "I'll handle the in-between frames for you." You declare an end state, specify a duration, and Flutter automatically interpolates from the current value to the new one. No AnimationController, no Ticker, no lifecycle management.
The most common implicit animation widget is AnimatedContainer. Think of it as a regular Container, but with built-in tween capabilities. When you change its color, size, padding, or decoration, it animates to the new value over the duration you specify.
class ExpandableButton extends StatefulWidget {
@override
State<ExpandableButton> createState() => _ExpandableButtonState();
}
class _ExpandableButtonState extends State<ExpandableButton> {
bool isExpanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
isExpanded = !isExpanded;
});
},
child: AnimatedContainer(
duration: Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: isExpanded ? 200 : 100,
height: 50,
decoration: BoxDecoration(
color: isExpanded ? Colors.blue : Colors.grey,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(isExpanded ? 'Expanded' : 'Tap'),
),
),
);
}
}
Here's what's happening: you tap, isExpanded toggles, the widget rebuilds with new width and color, and AnimatedContainer smoothly transitions between the old and new values over 300 milliseconds. No boilerplate. No controller. That's the power of implicit animations.
Other implicit animation widgets include AnimatedOpacity for fade effects, AnimatedPositioned for layout shifts inside a Stack, AnimatedDefaultTextStyle for typography transitions, and AnimatedPadding for spacing changes. Each one works the same way: define the new state, and Flutter interpolates.
Implicit animations shine in UI patterns: hover effects on buttons, expanding panels, color transitions on state changes, and simple transitions between screens. They're declarative, easy to reason about, and rarely introduce performance issues.
Explicit animations — AnimationController for full control.
When implicit animations aren't enough—when you need to coordinate multiple animations, reverse them programmatically, or drive animations based on user input like a swipe gesture—you reach for explicit animations. This means you own an AnimationController.
An AnimationController is a Listenable that produces values from 0 to 1 (or custom ranges via Tween). You control when it starts, stops, reverses, and repeats. It's lower-level than implicit animations, which means more power and more responsibility.
class InteractiveCard extends StatefulWidget {
@override
State<InteractiveCard> createState() => _InteractiveCardState();
}
class _InteractiveCardState extends State<InteractiveCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Duration(seconds: 2),
vsync: this,
);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
);
_rotationAnimation = Tween<double>(begin: 0, end: 2 * 3.14159).animate(
CurvedAnimation(parent: _controller, curve: Curves.linear),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _playAnimation() {
_controller.forward().then((_) {
_controller.reverse();
});
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _playAnimation,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value,
child: Card(
elevation: 4,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.purple,
borderRadius: BorderRadius.circular(12),
),
child: Center(
child: Text(
'Tap',
style: TextStyle(color: Colors.white),
),
),
),
),
),
);
},
),
);
}
}
Here's what makes this explicit: you create a controller with a specific duration, attach a Ticker via SingleTickerProviderStateMixin, define multiple tweens for different properties, wrap them with CurvedAnimation, and rebuild whenever the animation changes using AnimatedBuilder. You call _controller.forward() to start, chain .then() to reverse, and you're responsible for disposing the controller.
Explicit animations are necessary for:
- Coordinated multi-property animations: Scaling and rotating at different rates simultaneously.
- Gesture-driven animations: Dragging a slider updates animation progress in real time.
- Repeating or looping animations: Loading spinners, breathing effects.
- Sequential animations: One animation finishes, then trigger the next.
- Complex choreography: Staggered list item entries, synchronized fade-in of multiple elements.
The tradeoff is complexity. You manage lifecycle, memory, and tickers. You write more code. But you get granular control that implicit animations simply don't offer.
Tweens and curves — Define ranges and easing.
Both implicit and explicit animations rely on tweens and curves to feel natural. A Tween maps the controller's 0-1 output to a meaningful range. A Curve modulates how quickly you move through that range.
If you animate opacity from fully transparent to fully opaque, your tween goes from 0 to 1. If you animate a position from 0 pixels to 500 pixels, your tween goes from 0 to 500. The curve decides whether you accelerate into the motion (Curves.easeIn), ease out of it (Curves.easeOut), or apply elastic bounce (Curves.elasticOut).
// Implicit: tween is built-in
AnimatedContainer(
duration: Duration(milliseconds: 500),
curve: Curves.elasticOut,
width: isExpanded ? 300 : 100,
// Implicitly tweens from current width to 300 or 100
);
// Explicit: tween is explicit
late Animation<Offset> slideAnimation;
slideAnimation = Tween<Offset>(
begin: Offset(-1, 0),
end: Offset(0, 0),
).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
// Use slideAnimation.value in a Transform.translate()
Flutter includes dozens of curves: linear, quadratic, cubic, bounce, elastic. For custom easing, you can define your own by extending Curve. The right curve makes an animation feel responsive or graceful; a bad choice makes it feel sluggish or jarring.
Pro tip: use Curves.easeInOut as your default. It feels balanced. Use Curves.linear for rotations and continuous loops. Use Curves.elasticOut or Curves.bounceOut for playful, natural-feeling responses.
Start implicit — Most UI animations don't need controllers.
This is the practical takeaway: reach for implicit animations first. They're simpler, faster to implement, and handle the vast majority of real-world use cases. Only when you hit their limits—when you need to coordinate animations, reverse them programmatically, or drive them by gesture—do you pay the cost of an explicit controller.
A typical product app spends 90% of its animation time in implicit widgets like AnimatedContainer, AnimatedOpacity, and AnimatedSlide. The other 10% requires AnimationController.
When you're deciding between implicit and explicit, ask yourself:
- Is this a simple state change (button color, container size, text opacity)? Use implicit.
- Do I need to coordinate multiple properties or respond to continuous input? Use explicit.
- Does the animation need to loop or reverse on demand? Probably explicit.
- Is this a one-off transition that fires once? Implicit every time.
In production e-commerce or CRM platforms, I've seen teams build entire onboarding flows, checkout sequences, and data-table animations without a single AnimationController. They composed implicit animations, and the result was maintainable and performant. Controllers appeared only in complex gestures—a swipe-to-delete card, a draggable slider for filtering, an interactive chart that responds to finger movement.
Common mistakes
- Using explicit animations for implicit scenarios: If you only need to animate a property on state change,
AnimatedContaineris faster and cleaner than a controller. Resist over-engineering. - Forgetting to dispose controllers: Memory leaks. Always dispose in your
dispose()method and use the right mixin (SingleTickerProviderStateMixinfor one controller,TickerProviderStateMixinfor multiple). - Not using the right vsync: A controller without a proper
Tickerwill animate even when your screen is off-frame, wasting battery. Always passvsync: this. - Ignoring curves: Animation curves matter more than you think. The default linear curve makes things feel robotic. Experiment with
Curves.easeInOutandCurves.elasticOut. - Animating on every rebuild: If your implicit animation rebuilds and triggers a new animation every frame, you've created a loop. Make sure the animated value only changes when your business logic says it should.
Key takeaways
- Implicit animations like
AnimatedContainerandAnimatedOpacityare your default choice. Declare the new state, let Flutter interpolate. - Explicit animations with
AnimationControllergive you full control for complex, coordinated, or gesture-driven sequences. - Tweens define the value range; curves define the easing. Use
Curves.easeInOutas your baseline. - Start simple. Most production animations are implicit. Only reach for controllers when you genuinely need them.
- Always dispose controllers and use the correct
TickerProvidermixin to avoid memory leaks and battery drain. - Test your animations with the Profiler. Smooth, jank-free animations are a feature, not a luxury.






Comments
Loading comments…