Master Flutter Animations: From Implicit to Custom Painting

Animations are what separate a functional app from one that feels truly polished. After shipping several Flutter apps, I can say confidently that thoughtful animations are one of the highest-impact improvements you can make to user experience. Flutter gives us an incredibly powerful animation system — let’s walk through it from the simplest to the most advanced techniques.

Implicit Animations: The Easy Win

Implicit animations are Flutter’s simplest animation approach. You define the target state, and Flutter handles the transition automatically. No controllers, no manual management.

AnimatedContainer

The workhorse of implicit animations. Any property change on an AnimatedContainer automatically animates:

AnimatedContainer(
  duration: const Duration(milliseconds: 300),
  curve: Curves.easeInOut,
  width: _isExpanded ? 200 : 100,
  height: _isExpanded ? 200 : 100,
  decoration: BoxDecoration(
    color: _isExpanded ? Colors.indigo : Colors.amber,
    borderRadius: BorderRadius.circular(_isExpanded ? 24 : 8),
  ),
  child: const Icon(Icons.star, color: Colors.white),
)

When _isExpanded changes via setState, the container smoothly transitions its size, color, and border radius all at once. I use this constantly for button hover states and card expansions in my apps.

AnimatedOpacity

Perfect for fade-in and fade-out effects:

AnimatedOpacity(
  duration: const Duration(milliseconds: 500),
  opacity: _isVisible ? 1.0 : 0.0,
  child: const Text('Hello, World!'),
)

AnimatedSwitcher

When you need to animate between two different widgets:

AnimatedSwitcher(
  duration: const Duration(milliseconds: 400),
  transitionBuilder: (child, animation) {
    return ScaleTransition(scale: animation, child: child);
  },
  child: Text(
    '$_counter',
    key: ValueKey<int>(_counter),
  ),
)

The key is critical here — it tells Flutter that the widget has actually changed and should trigger the transition.

Other useful implicit animations include AnimatedPadding, AnimatedPositioned (inside a Stack), AnimatedDefaultTextStyle, and AnimatedCrossFade. The pattern is always the same: set a duration, change a property, and Flutter handles the rest.

Explicit Animations: Full Control

When you need precise control over timing, sequencing, or want to loop animations, you need explicit animations with an AnimationController.

Setting Up an AnimationController

class _PulseWidgetState extends State<PulseWidget>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;
  late final Animation<double> _scaleAnimation;

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

    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.2).animate(
      CurvedAnimation(parent: _controller, curve: Curves.elasticOut),
    );

    _controller.repeat(reverse: true);
  }

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

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scaleAnimation,
      child: const Icon(Icons.favorite, color: Colors.red, size: 48),
    );
  }
}

Key concepts here:

  • SingleTickerProviderStateMixin provides the vsync parameter, which ties the animation to the screen refresh rate.
  • Tween defines the range of values — here, scaling from 1.0 to 1.2.
  • CurvedAnimation applies an easing curve to make the motion feel natural.
  • Always dispose your controllers to prevent memory leaks.

Tween and CurvedAnimation

Tweens aren’t limited to doubles. Flutter provides tweens for colors, offsets, decorations, and more:

final colorAnimation = ColorTween(
  begin: Colors.blue,
  end: Colors.red,
).animate(_controller);

final slideAnimation = Tween<Offset>(
  begin: const Offset(-1.0, 0.0),
  end: Offset.zero,
).animate(CurvedAnimation(
  parent: _controller,
  curve: Curves.easeOutCubic,
));

You can combine these with SlideTransition, FadeTransition, RotationTransition, and other built-in transition widgets.

AnimatedBuilder vs AnimatedWidget

There are two patterns for consuming animation values. Both work; the choice depends on your preference.

AnimatedBuilder keeps the animation logic separate from the widget:

AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return Transform.rotate(
      angle: _controller.value * 2 * pi,
      child: child,
    );
  },
  child: const Icon(Icons.refresh, size: 48),
)

The child parameter is important — it prevents the child widget from rebuilding on every frame, which is a significant performance optimization.

AnimatedWidget encapsulates the animation inside a custom widget:

class SpinningIcon extends AnimatedWidget {
  const SpinningIcon({super.key, required Animation<double> animation})
      : super(listenable: animation);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Transform.rotate(
      angle: animation.value * 2 * pi,
      child: const Icon(Icons.refresh, size: 48),
    );
  }
}

I prefer AnimatedBuilder for one-off animations and AnimatedWidget when I want a reusable animated component.

Hero Transitions

Hero animations create a visual connection between two screens by animating a shared element during navigation. They’re surprisingly simple in Flutter:

// On the source screen
Hero(
  tag: 'app-icon-${app.id}',
  child: Image.asset(app.iconPath, width: 64, height: 64),
)

// On the destination screen
Hero(
  tag: 'app-icon-${app.id}',
  child: Image.asset(app.iconPath, width: 200, height: 200),
)

Flutter automatically animates the widget from its position and size on the first screen to its position and size on the second screen. The only requirement is that both Hero widgets share the same tag.

In my apps, I use Hero transitions for app icons on listing pages that expand into detail views. It creates a feeling of continuity that users notice even if they can’t articulate why.

Staggered Animations

Staggered animations create sequences where different elements animate at different times, producing a cascading effect. This is achieved by using Interval within a single AnimationController:

late final Animation<double> _fadeAnimation = Tween<double>(
  begin: 0.0, end: 1.0,
).animate(CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.0, 0.5, curve: Curves.easeIn),
));

late final Animation<Offset> _slideAnimation = Tween<Offset>(
  begin: const Offset(0, 0.3), end: Offset.zero,
).animate(CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.3, 0.8, curve: Curves.easeOut),
));

late final Animation<double> _scaleAnimation = Tween<double>(
  begin: 0.8, end: 1.0,
).animate(CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.5, 1.0, curve: Curves.elasticOut),
));

Each Interval defines when within the controller’s duration that particular animation runs. Here, the fade starts first, the slide begins before the fade finishes, and the scale starts last — creating a layered, polished entrance effect.

I use staggered animations for list items appearing on screen. Each item starts its animation slightly after the previous one, creating a waterfall effect that feels dynamic without being distracting.

Custom Animations with CustomPainter

For effects that go beyond standard widgets — particle systems, custom progress indicators, drawing animations — you need CustomPainter:

class WaveProgressPainter extends CustomPainter {
  final double progress;
  final double wavePhase;

  WaveProgressPainter({required this.progress, required this.wavePhase});

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()
      ..color = Colors.indigo.withValues(alpha: 0.6)
      ..style = PaintingStyle.fill;

    final path = Path();
    path.moveTo(0, size.height);

    for (double x = 0; x <= size.width; x++) {
      final y = size.height * (1 - progress) +
          sin((x / size.width * 2 * pi) + wavePhase) * 8;
      path.lineTo(x, y);
    }

    path.lineTo(size.width, size.height);
    path.close();
    canvas.drawPath(path, paint);
  }

  @override
  bool shouldRepaint(WaveProgressPainter oldDelegate) {
    return oldDelegate.progress != progress ||
        oldDelegate.wavePhase != wavePhase;
  }
}

This creates a wave-like fill effect. By animating progress and wavePhase with an AnimationController, you get a smooth, continuously moving wave that fills up — perfect for loading indicators or game effects.

In Happy Balloon Pop, I use CustomPainter for particle effects when balloons burst. The explosion of colorful particles is entirely hand-painted on the canvas, giving me complete control over every pixel.

Performance Tips

Animations that run at 60fps look smooth. Animations that drop frames look janky. Here’s how to keep performance solid:

Use RepaintBoundary. Wrap animated widgets in RepaintBoundary to prevent them from causing their parent tree to repaint:

RepaintBoundary(
  child: MyAnimatedWidget(),
)

Avoid rebuilding widget trees during animation. Use the child parameter in AnimatedBuilder to cache static children. This is probably the most common performance mistake I see.

Keep paint operations simple. In CustomPainter, minimize complex path calculations. Pre-compute what you can in initState rather than in every paint call.

Profile with DevTools. Flutter’s performance overlay and the DevTools timeline view will show you exactly where frames are being dropped. Don’t optimize blindly — measure first.

Use addPostFrameCallback for entry animations. Starting animations in initState can cause jank during page transitions. Deferring to after the first frame ensures a smooth start:

@override
void initState() {
  super.initState();
  WidgetsBinding.instance.addPostFrameCallback((_) {
    _controller.forward();
  });
}

Bringing It All Together

The key insight I’ve gained from building animated interfaces is that restraint matters more than complexity. A single, well-timed 300ms ease-out transition does more for your app’s feel than a dozen flashy effects.

Start with implicit animations — they handle 80% of use cases with zero boilerplate. Move to explicit animations when you need looping, sequencing, or precise timing. Reserve CustomPainter for truly custom visual effects where no existing widget will do.

Every animation in your app should answer the question: “Does this help the user understand what just happened?” If a button press triggers a color change, animate it so the user sees the transition. If a list reorders, animate the items moving. If content loads, fade it in rather than popping it onto screen.

Flutter’s animation system is one of its greatest strengths. Once you internalize these patterns, adding polish to your apps becomes second nature. Start small, measure performance, and iterate. Your users will feel the difference.