← Articles

Flutter animations: implicit vs explicit

By Charlin Joe · 16 June 2025

Flutter has two animation systems: implicit and explicit. Choosing the wrong one leads to overly complex code for simple cases, or inflexible code for complex ones. Here is when to use each.

Implicit animations: widget-managed

Implicit animations transition between property values automatically when they change. Flutter handles the animation controller, tween, and rebuilding.

Use implicit animations for: simple value transitions where you just want "animate from old to new" without controlling playback.

// AnimatedContainer: animates any property change
AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  width: isExpanded ? 200 : 100,
  height: isExpanded ? 200 : 100,
  decoration: BoxDecoration(
    color: isExpanded ? Colors.blue : Colors.grey,
    borderRadius: BorderRadius.circular(isExpanded ? 32 : 8),
  ),
)

// AnimatedOpacity: fade in/out
AnimatedOpacity(
  duration: const Duration(milliseconds: 200),
  opacity: isVisible ? 1.0 : 0.0,
  child: const MyWidget(),
)

// AnimatedSwitcher: animate when the child changes
AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  transitionBuilder: (child, animation) => FadeTransition(
    opacity: animation,
    child: child,
  ),
  child: isLoading
      ? const CircularProgressIndicator(key: ValueKey('loading'))
      : const OrdersList(key: ValueKey('orders')),
)

// TweenAnimationBuilder: animate any value with a Tween
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0.0, end: progress),
  duration: const Duration(milliseconds: 500),
  builder: (_, value, child) {
    return LinearProgressIndicator(value: value);
  },
)

Built-in implicit widgets

  • AnimatedContainer — size, color, decoration, padding
  • AnimatedOpacity / AnimatedCrossFade
  • AnimatedAlign / AnimatedPositioned
  • AnimatedPadding
  • AnimatedDefaultTextStyle
  • AnimatedSwitcher — swap child widgets with animation
  • TweenAnimationBuilder — any custom value

Explicit animations: controller-managed

Explicit animations give you a AnimationController you drive manually. Use them for:

  • Looping animations (loading spinner, pulse)
  • Animations triggered by events (not property changes)
  • Chained or sequenced animations
  • Physics-based animations
  • Animations that need to be paused, reversed, or speed-changed
class PulseButton extends StatefulWidget {
  const PulseButton({super.key, required this.onPressed, required this.child});
  final VoidCallback onPressed;
  final Widget child;

  @override
  State<PulseButton> createState() => _PulseButtonState();
}

class _PulseButtonState extends State<PulseButton>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _scaleAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 150),
    );
    _scaleAnimation = Tween<double>(begin: 1.0, end: 0.95).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _onTap() async {
    await _controller.forward();
    await _controller.reverse();
    widget.onPressed();
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _onTap,
      child: ScaleTransition(
        scale: _scaleAnimation,
        child: widget.child,
      ),
    );
  }
}

Looping animations

// Start a continuous loop
_controller.repeat(reverse: true); // Ping-pong loop
_controller.repeat();              // Forward loop

// RotationTransition for a loading spinner
RotationTransition(
  turns: _controller,  // 0.0 to 1.0 = full rotation
  child: const Icon(Icons.refresh),
)

Sequence with Interval

final slideIn = Tween<Offset>(
  begin: const Offset(0, 1),
  end: Offset.zero,
).animate(CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.0, 0.6, curve: Curves.easeOut), // Runs 0-60% of animation
));

final fadeIn = Tween<double>(begin: 0, end: 1).animate(
  CurvedAnimation(
    parent: _controller,
    curve: const Interval(0.4, 1.0, curve: Curves.easeIn), // Runs 40-100%
  ),
);

// Start both from a single controller.forward()

Hero animations: shared element transitions

// In list:
Hero(
  tag: 'product-${product.id}',
  child: ProductImage(url: product.imageUrl),
)

// In detail screen:
Hero(
  tag: 'product-${product.id}',
  child: ProductImage(url: product.imageUrl, fullSize: true),
)

Flutter automatically animates the image flying between screens when navigating.

Decision guide

Animating a property when it changes value?
  → Implicit (AnimatedContainer, AnimatedOpacity, etc.)

Need to loop, sequence, or control playback?
  → Explicit (AnimationController)

Shared element between screens?
  → Hero widget

Physics-based (spring, bounce)?
  → Explicit with SpringSimulation or PhysicsBasedAnimation

Complex custom animation (drawn, shader)?
  → Explicit with CustomPainter

Common pitfalls

Not disposing AnimationController. Always dispose in dispose(). A leaked controller keeps the animation ticker running, burning battery and causing memory leaks.

Using AnimationController without vsync. Always pass vsync: this and mix in SingleTickerProviderStateMixin (for one animation) or TickerProviderStateMixin (for multiple). Without vsync, animations run at full speed even when off-screen.

Calling setState in animation listeners. Animation builders (AnimatedBuilder, AnimationBuilder) rebuild efficiently without setState. Calling setState inside an addListener rebuilds the entire widget tree on every animation frame.

Sign in to like, dislike, or report.

Flutter animations: implicit vs explicit — ANN Tech