← Articles

Flutter animations: from basics to production patterns

By Ann Tech · 12 April 2026

Flutter's animation system is layered — simple cases need almost no code, complex cases give you full control. The key is knowing which layer to use.

Layer 1: Implicit animations (simplest)

When a value changes, animate to the new value automatically:

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  width: _isExpanded ? 200 : 100,
  height: _isExpanded ? 200 : 100,
  color: _isExpanded ? Colors.blue : Colors.grey,
  child: child,
)

AnimatedOpacity(
  duration: const Duration(milliseconds: 200),
  opacity: _isVisible ? 1.0 : 0.0,
  child: child,
)

AnimatedSwitcher(
  duration: const Duration(milliseconds: 300),
  child: _showA ? const WidgetA(key: Key('a')) : const WidgetB(key: Key('b')),
)

Use implicit animations for: hover effects, show/hide transitions, value changes triggered by user actions.

Layer 2: TweenAnimationBuilder

Animate any value that doesn't have a built-in Animated* widget:

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: _progress),
  duration: const Duration(milliseconds: 600),
  curve: Curves.easeOut,
  builder: (context, value, child) {
    return CircularProgressIndicator(value: value);
  },
)

Layer 3: Explicit AnimationController

For looping, reversing, chaining, or precise timing control:

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

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 800),
    )..repeat(reverse: true);

    _scale = Tween<double>(begin: 1.0, end: 1.1).animate(
      CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
    );
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scale,
      child: widget.child,
    );
  }

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

Staggered animations

Animate multiple elements with offset delays:

class _StaggeredListState extends State<StaggeredList>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      vsync: this,
      duration: const Duration(milliseconds: 1200),
    )..forward();
  }

  Animation<double> _itemAnimation(int index) {
    final start = index * 0.1;  // Each item starts 10% later
    final end = start + 0.6;
    return CurvedAnimation(
      parent: _controller,
      curve: Interval(start.clamp(0.0, 1.0), end.clamp(0.0, 1.0),
          curve: Curves.easeOut),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: widget.items.asMap().entries.map((e) {
        return FadeTransition(
          opacity: _itemAnimation(e.key),
          child: SlideTransition(
            position: Tween<Offset>(begin: const Offset(0, 0.3), end: Offset.zero)
                .animate(_itemAnimation(e.key)),
            child: e.value,
          ),
        );
      }).toList(),
    );
  }
}

Hero animations

Shared element transitions between screens:

// Source screen
Hero(
  tag: 'product-image-${product.id}',
  child: CachedNetworkImage(imageUrl: product.imageUrl),
)

// Destination screen
Hero(
  tag: 'product-image-${product.id}',
  child: CachedNetworkImage(imageUrl: product.imageUrl, fit: BoxFit.cover),
)

Flutter handles the transition automatically. The tag must match exactly.

Performance tips

  • Use const for animation targets when possible
  • Wrap animating subtrees in RepaintBoundary to isolate repainting
  • Use Transform widgets (ScaleTransition, SlideTransition) instead of rebuilding layout — they run on the raster thread
  • Profile in flutter run --profile mode — flutter run --debug hides real performance issues

Common pitfalls

Not disposing AnimationController. Every controller allocates a ticker that runs forever. Always call _controller.dispose() in dispose(). Forgetting this causes memory leaks and "animation after dispose" exceptions.

Animating layout properties unnecessarily. Animating width and height triggers layout on every frame. Prefer Transform.scale (no layout) over AnimatedContainer(width:) for performance-sensitive animations.

Using setState for animation values. Calling setState on every animation tick rebuilds the entire widget subtree. Use AnimatedBuilder or *Transition widgets which only rebuild what's necessary.

Sign in to like, dislike, or report.

Flutter animations: from basics to production patterns — ANN Tech