Introduction

Animation transforms static interfaces into engaging, intuitive experiences. In Flutter, the animation system is both powerful and approachable, enabling developers to create smooth, 60fps animations without deep graphics programming knowledge.

Flutter’s animation framework builds on the reactive paradigm that makes the framework so productive. Animations are just values that change over time, triggering rebuilds that reflect the new state.

This guide walks through Flutter’s animation capabilities, from simple implicit animations to complex choreographed sequences.

Why Animation Matters

User Experience Benefits

Well-designed animations serve concrete purposes:

Provide Feedback When users tap a button, animation confirms the action registered. Without feedback, users wonder if the app is responding.

Guide Attention Animation draws eyes to important changes. A badge count incrementing, a new item appearing in a list, or a validation error all benefit from motion that directs focus.

Create Context Transitions between screens show spatial relationships. A detail view expanding from a list item communicates hierarchy. A slide-in drawer reveals where content lives.

Delight Users Beyond function, thoughtful animation creates emotional connection. An app that feels alive and responsive builds user trust and satisfaction.

Performance Considerations

Flutter’s architecture enables smooth animation performance:

  • Compiled to native code: No JavaScript bridge overhead
  • Own rendering engine: Skia provides consistent graphics
  • 60fps target: Framework optimized for animation

However, poor implementation can still cause jank. We will cover performance best practices throughout this guide.

Implicit Animations

Implicit

animations are the easiest way to add motion to your app. You describe the end state, and Flutter handles the transition.

AnimatedContainer

The workhorse of implicit animations:

class ExpandingBox extends StatefulWidget {
  @override
  _ExpandingBoxState createState() => _ExpandingBoxState();
}

class _ExpandingBoxState extends State<ExpandingBox> {
  bool _expanded = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () {
        setState(() {
          _expanded = !_expanded;
        });
      },
      child: AnimatedContainer(
        duration: Duration(milliseconds: 300),
        curve: Curves.easeInOut,
        width: _expanded ? 200 : 100,
        height: _expanded ? 200 : 100,
        decoration: BoxDecoration(
          color: _expanded ? Colors.blue : Colors.red,
          borderRadius: BorderRadius.circular(_expanded ? 20 : 10),
        ),
        child: Center(
          child: Text('Tap me'),
        ),
      ),
    );
  }
}

AnimatedContainer interpolates between any properties you change: size, color, padding, margin, decoration, and more.

Other Implicit Animation Widgets

Flutter provides implicit versions of many common widgets:

AnimatedOpacity

AnimatedOpacity(
  opacity: _visible ? 1.0 : 0.0,
  duration: Duration(milliseconds: 500),
  child: Text('Fade in and out'),
)

AnimatedPadding

AnimatedPadding(
  padding: EdgeInsets.all(_expanded ? 32.0 : 8.0),
  duration: Duration(milliseconds: 200),
  child: Container(color: Colors.blue),
)

AnimatedPositioned (inside Stack)

AnimatedPositioned(
  duration: Duration(milliseconds: 300),
  left: _moved ? 100 : 0,
  top: _moved ? 100 : 0,
  child: Container(width: 50, height: 50, color: Colors.green),
)

AnimatedDefaultTextStyle

AnimatedDefaultTextStyle(
  style: _emphasized
      ? TextStyle(fontSize: 24, fontWeight: FontWeight.bold)
      : TextStyle(fontSize: 16, fontWeight: FontWeight.normal),
  duration: Duration(milliseconds: 200),
  child: Text('Animated text style'),
)

Choosing Animation Curves

The curve parameter controls the animation’s timing:

  • Curves.linear: Constant speed
  • Curves.easeIn: Starts slow, accelerates
  • Curves.easeOut: Starts fast, decelerates
  • Curves.easeInOut: Slow start and end
  • Curves.bounceOut: Bouncy finish
  • Curves.elasticOut: Springy overshoot

For most UI animations, Curves.easeInOut or Curves.easeOut feel natural.

Explicit Animations

When you nee

Explicit Animations Infographic d more control, explicit animations let you drive values directly.

AnimationController

The AnimationController orchestrates animation timing:

class PulsingCircle extends StatefulWidget {
  @override
  _PulsingCircleState createState() => _PulsingCircleState();
}

class _PulsingCircleState extends State<PulsingCircle>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 1),
      vsync: this,
    )..repeat(reverse: true);
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Transform.scale(
          scale: 0.8 + (_controller.value * 0.4),
          child: child,
        );
      },
      child: Container(
        width: 100,
        height: 100,
        decoration: BoxDecoration(
          color: Colors.blue,
          shape: BoxShape.circle,
        ),
      ),
    );
  }
}

Key points:

  • vsync: Ties to screen refresh rate, preventing off-screen animations
  • SingleTickerProviderStateMixin: Provides the vsync
  • dispose(): Always dispose controllers to prevent memory leaks

Tweens

Tweens define how to interpolate between values:

class ColorFade extends StatefulWidget {
  @override
  _ColorFadeState createState() => _ColorFadeState();
}

class _ColorFadeState extends State<ColorFade>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<Color?> _colorAnimation;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: Duration(seconds: 2),
      vsync: this,
    );

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

    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _colorAnimation,
      builder: (context, child) {
        return Container(
          width: 100,
          height: 100,
          color: _colorAnimation.value,
        );
      },
    );
  }
}

Common Tween types:

  • Tween<double>: Numeric values
  • ColorTween: Color interpolation
  • SizeTween: Size objects
  • RectTween: Rectangle interpolation
  • AlignmentTween: Alignment values

Curved Animations

Apply easing to explicit animations:

_animation = CurvedAnimation(
  parent: _controller,
  curve: Curves.easeOutBack,
);

_sizeAnimation = Tween&lt;double&gt;(begin: 0, end: 100).animate(_animation);

Animation States and Listeners

AnimationController provides methods and callbacks:

// Control methods
_controller.forward();   // Play forward
_controller.reverse();   // Play backward
_controller.repeat();    // Loop
_controller.stop();      // Pause
_controller.reset();     // Reset to beginning

// Status listener
_controller.addStatusListener((status) {
  if (status == AnimationStatus.completed) {
    // Animation finished
  }
});

// Value listener
_controller.addListener(() {
  // Called every frame
  print(_controller.value);
});

Hero Animations

Hero animati

ons create seamless transitions between screens by animating shared elements.

Basic Hero Usage

On the source screen:

Hero(
  tag: 'product-image-${product.id}',
  child: Image.network(product.imageUrl),
)

On the destination screen:

Hero(
  tag: 'product-image-${product.id}',
  child: Image.network(product.imageUrl),
)

The tag must match exactly. When navigating, Flutter automatically animates the widget between positions.

Customizing Hero Animations

Control the flight path:

Hero(
  tag: 'profile-avatar',
  flightShuttleBuilder: (
    BuildContext flightContext,
    Animation&lt;double&gt; animation,
    HeroFlightDirection flightDirection,
    BuildContext fromHeroContext,
    BuildContext toHeroContext,
  ) {
    return AnimatedBuilder(
      animation: animation,
      builder: (context, child) {
        return Material(
          color: Colors.transparent,
          child: CircleAvatar(
            radius: 24 + (animation.value * 36),
            backgroundImage: NetworkImage(avatarUrl),
          ),
        );
      },
    );
  },
  child: CircleAvatar(
    radius: 24,
    backgroundImage: NetworkImage(avatarUrl),
  ),
)

Staggered Animations

Create sequences where multiple animations play in choreographed order.

class StaggeredDemo extends StatefulWidget {
  @override
  _StaggeredDemoState createState() => _StaggeredDemoState();
}

class _StaggeredDemoState extends State<StaggeredDemo>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation&lt;double&gt; _opacity;
  late Animation&lt;double&gt; _width;
  late Animation&lt;double&gt; _height;
  late Animation<EdgeInsets> _padding;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: Duration(milliseconds: 2000),
      vsync: this,
    );

    _opacity = Tween&lt;double&gt;(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.0, 0.25, curve: Curves.ease),
      ),
    );

    _width = Tween&lt;double&gt;(begin: 50.0, end: 150.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.25, 0.5, curve: Curves.ease),
      ),
    );

    _height = Tween&lt;double&gt;(begin: 50.0, end: 150.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.5, 0.75, curve: Curves.ease),
      ),
    );

    _padding = EdgeInsetsTween(
      begin: EdgeInsets.only(bottom: 16.0),
      end: EdgeInsets.only(bottom: 75.0),
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: Interval(0.75, 1.0, curve: Curves.ease),
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return Container(
          padding: _padding.value,
          child: Opacity(
            opacity: _opacity.value,
            child: Container(
              width: _width.value,
              height: _height.value,
              color: Colors.blue,
            ),
          ),
        );
      },
    );
  }
}

The Interval class defines when each animation plays within the overall duration.

List Animations

Animating list items enhances perceived performance and provides visual context.

AnimatedList

For lists that change over time:

class AnimatedListDemo extends StatefulWidget {
  @override
  _AnimatedListDemoState createState() => _AnimatedListDemoState();
}

class _AnimatedListDemoState extends State<AnimatedListDemo> {
  final GlobalKey<AnimatedListState> _listKey = GlobalKey<AnimatedListState>();
  final List&lt;String&gt; _items = [];

  void _addItem() {
    final index = _items.length;
    _items.add('Item ${index + 1}');
    _listKey.currentState?.insertItem(index);
  }

  void _removeItem(int index) {
    final removedItem = _items.removeAt(index);
    _listKey.currentState?.removeItem(
      index,
      (context, animation) => _buildItem(removedItem, animation),
    );
  }

  Widget _buildItem(String item, Animation&lt;double&gt; animation) {
    return SizeTransition(
      sizeFactor: animation,
      child: Card(
        child: ListTile(
          title: Text(item),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        ElevatedButton(
          onPressed: _addItem,
          child: Text('Add Item'),
        ),
        Expanded(
          child: AnimatedList(
            key: _listKey,
            initialItemCount: _items.length,
            itemBuilder: (context, index, animation) {
              return _buildItem(_items[index], animation);
            },
          ),
        ),
      ],
    );
  }
}

Staggered List Item Animations

Animate list items with delays for a cascade effect:

class StaggeredListView extends StatefulWidget {
  final List<Widget> children;

  StaggeredListView({required this.children});

  @override
  _StaggeredListViewState createState() => _StaggeredListViewState();
}

class _StaggeredListViewState extends State<StaggeredListView>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

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

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

  @override
  Widget build(BuildContext context) {
    return ListView.builder(
      itemCount: widget.children.length,
      itemBuilder: (context, index) {
        final animation = Tween&lt;double&gt;(begin: 0.0, end: 1.0).animate(
          CurvedAnimation(
            parent: _controller,
            curve: Interval(
              index * 0.1,
              (index * 0.1) + 0.3,
              curve: Curves.easeOut,
            ),
          ),
        );

        return AnimatedBuilder(
          animation: animation,
          builder: (context, child) {
            return Transform.translate(
              offset: Offset(0, 50 * (1 - animation.value)),
              child: Opacity(
                opacity: animation.value,
                child: child,
              ),
            );
          },
          child: widget.children[index],
        );
      },
    );
  }
}

Physics-Based Animations

Spring physics create natural-feeling motion.

class SpringAnimation extends StatefulWidget {
  @override
  _SpringAnimationState createState() => _SpringAnimationState();
}

class _SpringAnimationState extends State<SpringAnimation>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation&lt;Offset&gt; _animation;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      vsync: this,
      duration: Duration(seconds: 1),
    );

    _animation = _controller.drive(
      Tween&lt;Offset&gt;(
        begin: Offset.zero,
        end: Offset(100, 0),
      ),
    );
  }

  void _runAnimation() {
    _controller.animateWith(
      SpringSimulation(
        SpringDescription(
          mass: 1,
          stiffness: 100,
          damping: 10,
        ),
        0,    // starting position
        1,    // ending position
        0,    // starting velocity
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: _runAnimation,
      child: AnimatedBuilder(
        animation: _animation,
        builder: (context, child) {
          return Transform.translate(
            offset: _animation.value,
            child: Container(
              width: 50,
              height: 50,
              color: Colors.blue,
            ),
          );
        },
      ),
    );
  }
}

Adjust spring parameters:

  • mass: Higher mass = slower, more momentum
  • stiffness: Higher stiffness = faster snap
  • damping: Higher damping = less bounce

Performance Best Practices

Use RepaintBoundary

Isolate animated widgets to prevent unnecessary repaints:

RepaintBoundary(
  child: AnimatedWidget(...),
)

Leverage Child Property

AnimatedBuilder accepts a child parameter for non-animated content:

AnimatedBuilder(
  animation: _controller,
  builder: (context, child) {
    return Transform.rotate(
      angle: _controller.value * 2 * pi,
      child: child,  // Reused, not rebuilt
    );
  },
  child: ExpensiveWidget(),  // Built once
)

Avoid Opacity on Complex Widgets

Opacity on large subtrees is expensive. Consider alternatives:

  • FadeTransition with opacity animation
  • Color animation instead of opacity
  • Visibility widget for show/hide

Profile Animation Performance

Use Flutter DevTools to identify jank:

  1. Run app in profile mode: flutter run --profile
  2. Open DevTools
  3. Navigate to Performance tab
  4. Record during animations
  5. Look for frames exceeding 16ms

Conclusion

Flutter’s animation system provides tools for every level of complexity. Start with implicit animations for simple state transitions. Progress to explicit animations when you need precise control. Use Hero animations for seamless navigation. Apply physics for natural motion.

The key to great animation is restraint. Animate with purpose, not for decoration. Every animation should serve user understanding or delight. Test animations on real devices across performance levels to ensure smooth experiences for all users.

As you build more Flutter apps, you will develop intuition for when and how to animate. The framework makes experimentation easy, so try different approaches and observe how they feel in practice.


Building a Flutter app that needs polished animations? The Awesome Apps team creates engaging mobile experiences with beautiful, performant motion design. Contact us to discuss your project.