Skip to content
Boring Doc
Official documentation (snapshot)doc

Animations tutorial

A tutorial showing how to build explicit animations in Flutter.

Source last updated Oct 21, 2026 · last synchronized 3 weeks ago · commit b3045439b7c0 · read on docs.flutter.dev

This is a rendered snapshot of the official page as stored by Boring Doc for provenance and search. Site-specific components (embedded demos, DartPad, images with relative paths) may not render; use the official page for the full experience. Content © The Flutter Authors, CC BY 3.0.
  • How to use the fundamental classes from the animation library to add animation to a widget.
  • When to use AnimatedWidget vs. AnimatedBuilder.

The examples build on each other, introducing you to different aspects of the animation library. The tutorial is built on essential concepts, classes, and methods in the animation library that you can learn about in Introduction to animations.

The Flutter SDK also provides built-in explicit animations, such as FadeTransition, SizeTransition, and SlideTransition. These widgets take an Animation object, often driven by an AnimationController, and update the UI as its value changes. They require less code than the custom explicit animations described in this tutorial.

The following sections walk you through several animation examples. Each section provides a link to the source code for that example.

Rendering animations

  • How to add basic animation to a widget using addListener() and setState().
  • Every time the Animation generates a new number, the callback registered with addListener() calls setState().
  • How to define an AnimationController with the required vsync parameter.
  • How to use the .. syntax in ..addListener, also known as Dart's cascade notation.

but doesn't render anything to the screen. To render with an Animation object, store the Animation object as a member of your widget, then use its value to decide how to draw.

Consider the following app that draws the Flutter logo without animation:

import 'package:material_ui/material_ui.dart';

void main() => runApp(const LogoApp());

class LogoApp extends StatefulWidget {
  const LogoApp({super.key});

  @override
  State<LogoApp> createState() => _LogoAppState();
}

class _LogoAppState extends State<LogoApp> {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: 300,
        width: 300,
        child: const FlutterLogo(),
      ),
    );
  }
}

App source: animate0

The following shows the same code modified to animate the logo to grow from nothing to full size. When defining an AnimationController, you must pass in a vsync object. The vsync parameter is described in the AnimationController section.

The changes from the non-animated example are highlighted:

- class _LogoAppState extends State<LogoApp> {
+ class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
+   late Animation<double> animation;
+   late AnimationController controller;
+
+   @override
+   void initState() {
+     super.initState();
+     controller = AnimationController(
+       duration: const Duration(seconds: 2),
+       vsync: this,
+     );
+     animation = Tween<double>(begin: 0, end: 300).animate(controller)
+       ..addListener(() {
+         setState(() {
+           // The state that has changed here is the animation object's value.
+         });
+       });
+     controller.forward();
+   }
+
    @override
    Widget build(BuildContext context) {
      return Center(
        child: Container(
          margin: const EdgeInsets.symmetric(vertical: 10),
-         height: 300,
-         width: 300,
+         height: animation.value,
+         width: animation.value,
          child: const FlutterLogo(),
        ),
      );
    }
+
+   @override
+   void dispose() {
+     controller.dispose();
+     super.dispose();
+   }
  }

App source: animate1

The callback registered with addListener() calls setState(), so every time the Animation generates a new number, the widget is marked for rebuilding. In build(), the container changes size because its height and width now use animation.value instead of a hardcoded value. Dispose of the controller when the State object is discarded to prevent memory leaks.

With these few changes, you've created your first animation in Flutter.

You might not be familiar with Dart's cascade notation—the two dots in ..addListener(). This syntax means that the addListener() method is called with the return value from animate(). Consider the following example:

animation = Tween<double>(begin: 0, end: 300).animate(controller)
  ..addListener(() {
    // ···
  });

This code is equivalent to:

animation = Tween<double>(begin: 0, end: 300).animate(controller);
animation.addListener(() {
    // ···
  });

To learn more about cascades, check out Cascade notation in the Dart language documentation.

  • How to use the AnimatedWidget helper class (instead of addListener() and setState()) to create a widget that animates.
  • Use AnimatedWidget to create a widget that performs a reusable animation. To separate the transition from the widget, use an AnimatedBuilder, as shown in the Refactoring with AnimatedBuilder section.
  • Examples of AnimatedWidget widgets in the Flutter API: AnimatedBuilder, AnimatedModalBarrier, DecoratedBoxTransition, FadeTransition, PositionedTransition, RelativePositionedTransition, RotationTransition, ScaleTransition, SizeTransition, SlideTransition.

the core widget code from the animation code. AnimatedWidget manages its own State object and animation listener, so you don't need to implement them in your subclass. Add the following AnimatedLogo class:

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

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Container(
        margin: const EdgeInsets.symmetric(vertical: 10),
        height: animation.value,
        width: animation.value,
        child: const FlutterLogo(),
      ),
    );
  }
}

AnimatedLogo uses the current value of the animation when drawing itself.

The LogoApp still manages the AnimationController and the Tween, and it passes the Animation object to AnimatedLogo:

  void main() => runApp(const LogoApp());

+ class AnimatedLogo extends AnimatedWidget {
+   const AnimatedLogo({super.key, required Animation<double> animation})
+     : super(listenable: animation);
+
+   @override
+   Widget build(BuildContext context) {
+     final animation = listenable as Animation<double>;
+     return Center(
+       child: Container(
+         margin: const EdgeInsets.symmetric(vertical: 10),
+         height: animation.value,
+         width: animation.value,
+         child: const FlutterLogo(),
+       ),
+     );
+   }
+ }
+
  class LogoApp extends StatefulWidget {
    // ...
  }

  class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
    // ...

    @override
    void initState() {
      super.initState();
      controller = AnimationController(
        duration: const Duration(seconds: 2),
        vsync: this,
      );
-     animation = Tween<double>(begin: 0, end: 300).animate(controller)
-       ..addListener(() {
-         setState(() {
-           // The state that has changed here is the animation object's value.
-         });
-       });
+     animation = Tween<double>(begin: 0, end: 300).animate(controller);
      controller.forward();
    }

    @override
-   Widget build(BuildContext context) {
-     return Center(
-       child: Container(
-         margin: const EdgeInsets.symmetric(vertical: 10),
-         height: animation.value,
-         width: animation.value,
-         child: const FlutterLogo(),
-       ),
-     );
-   }
+   Widget build(BuildContext context) => AnimatedLogo(animation: animation);

    // ...
  }

App source: animate2

Monitoring the progress of the animation

  • Use addStatusListener() for notifications of changes to the animation's status, such as starting, reaching an endpoint, or reversing direction.
  • Run an animation in an infinite loop by reversing direction when the animation has either completed or returned to its starting state, or by calling controller.repeat(reverse: true).

such as finishing, moving forward, or reversing. You can get notifications for this with addStatusListener(). The following code modifies the previous example so that it listens for a state change and prints an update. The highlighted line shows the change:

class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    animation = Tween<double>(begin: 0, end: 300).animate(controller)
      ..addStatusListener((status) => debugPrint('$status'));
    controller.forward();
  }
  // ...
}

Running this code produces this output:

AnimationStatus.forward
AnimationStatus.completed

Next, use addStatusListener() to reverse the animation at the beginning or the end. This creates a "breathing" effect:

  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
-   animation = Tween<double>(begin: 0, end: 300).animate(controller);
+   animation = Tween<double>(begin: 0, end: 300).animate(controller)
+     ..addStatusListener((status) {
+       if (status == AnimationStatus.completed) {
+         controller.reverse();
+       } else if (status == AnimationStatus.dismissed) {
+         controller.forward();
+       }
+     })
+     ..addStatusListener((status) => debugPrint('$status'));
    controller.forward();
  }

App source: animate3

While addStatusListener() is useful for reacting to status changes, if your only goal is to loop an animation continuously in one or both directions, you can call controller.repeat(reverse: true) instead of manually toggling forward() and reverse() in a status listener. Calling stop() doesn't change the animation's status, so it doesn't notify status listeners.

  • An AnimatedBuilder separates the transition from the widget being animated.
  • An AnimatedBuilder doesn't render the widget itself, nor does it manage the Animation object.
  • Use AnimatedBuilder to describe an animation as part of a build() method for another widget. To define a widget with a reusable animation, use an AnimatedWidget, as shown in the Simplifying with AnimatedWidget section.
  • Examples of widgets that use AnimatedBuilder in the Flutter API: BottomSheet, ExpansionTile, PopupMenu, ProgressIndicator, RefreshIndicator, Scaffold, SnackBar, TabBar, TextField.

is that changing the animation required changing the widget that renders the logo. A better solution is to separate responsibilities into different classes:

  • Render the logo
  • Define the Animation object
  • Render the transition

You can accomplish this separation with the help of the AnimatedBuilder class (or its general-purpose counterpart, ListenableBuilder). An AnimatedBuilder is a separate widget in the widget tree. Like AnimatedWidget, AnimatedBuilder automatically listens to notifications from the Animation object, and marks the widget tree dirty as necessary, so you don't need to call addListener().

The widget tree for the animate4 example looks like this:

Starting from the bottom of the widget tree, the code for rendering the logo is straightforward:

class LogoWidget extends StatelessWidget {
  const LogoWidget({super.key});

  // Leave out the height and width so it fills the animating parent.
  @override
  Widget build(BuildContext context) {
    return Container(
      margin: const EdgeInsets.symmetric(vertical: 10),
      child: const FlutterLogo(),
    );
  }
}

The middle three blocks in the diagram are all created in the build() method in GrowTransition, shown below. The GrowTransition widget itself is stateless and holds the set of final variables necessary to define the transition animation. The build() method creates and returns the AnimatedBuilder, which takes the anonymous builder callback and the LogoWidget object as parameters. The work of rendering the transition actually happens in the anonymous builder callback, which creates a SizedBox (labeled Container in the diagram) of the appropriate size to force the LogoWidget to shrink to fit.

One tricky point in the code below is that the child looks like it's specified twice. What's happening is that the outer reference to child is passed to AnimatedBuilder, which passes it to the anonymous closure, which then uses that object as its child. The net result is that the AnimatedBuilder is inserted in between the two widgets in the widget tree, and animation ticks don't rebuild the static child subtree.

class GrowTransition extends StatelessWidget {
  const GrowTransition({
    required this.child,
    required this.animation,
    super.key,
  });

  final Widget child;
  final Animation<double> animation;

  @override
  Widget build(BuildContext context) {
    return Center(
      child: AnimatedBuilder(
        animation: animation,
        builder: (context, child) {
          return SizedBox(
            height: animation.value,
            width: animation.value,
            child: child,
          );
        },
        child: child,
      ),
    );
  }
}

Finally, the code to initialize the animation looks very similar to the animate2 example. The initState() method creates an AnimationController and a Tween, then binds them with animate(). Next, the build() method returns a GrowTransition object with a LogoWidget as a child, and an animation object to drive the transition. These are the three elements listed in the bullet points above.

  void main() => runApp(const LogoApp());

+ class LogoWidget extends StatelessWidget {
+   const LogoWidget({super.key});
+
+   // Leave out the height and width so it fills the animating parent.
+   @override
+   Widget build(BuildContext context) {
+     return Container(
+       margin: const EdgeInsets.symmetric(vertical: 10),
+       child: const FlutterLogo(),
+     );
+   }
+ }
+
+ class GrowTransition extends StatelessWidget {
+   const GrowTransition({
+     required this.child,
+     required this.animation,
+     super.key,
+   });
+
+   final Widget child;
+   final Animation<double> animation;
+
+   @override
+   Widget build(BuildContext context) {
+     return Center(
+       child: AnimatedBuilder(
+         animation: animation,
+         builder: (context, child) {
+           return SizedBox(
+             height: animation.value,
+             width: animation.value,
+             child: child,
+           );
+         },
+         child: child,
+       ),
+     );
+   }
+ }

  class LogoApp extends StatefulWidget {
    // ...
  }

  class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
    // ...

    @override
-   Widget build(BuildContext context) => AnimatedLogo(animation: animation);
+   Widget build(BuildContext context) {
+     return GrowTransition(animation: animation, child: const LogoWidget());
+   }

    // ...
  }

App source: animate4

Simultaneous animations

Monitoring the progress of the animation (animate3), which used AnimatedWidget to animate in and out continuously. Consider the case where you want to animate in and out while the opacity animates from transparent to opaque.

controller, where each tween manages a different effect in the animation. It is for illustrative purposes only. For opacity animations in production code, prefer FadeTransition to avoid rebuilding widgets on every frame. For size changes, consider SizeTransition when you want to animate how much of a child is revealed.

controller = AnimationController(
  duration: const Duration(seconds: 2),
  vsync: this,
);
sizeAnimation = Tween<double>(begin: 0, end: 300).animate(controller);
opacityAnimation = Tween<double>(begin: 0.1, end: 1).animate(controller);

You can get the size with sizeAnimation.value and the opacity with opacityAnimation.value. However, AnimatedLogo already listens to the controller's animation, so it can calculate both values from that single animation instead.

Change AnimatedLogo to encapsulate its own Tween objects, and its build() method calls Tween.evaluate() on the parent's animation object to calculate the required size and opacity values. The following code shows the changes with highlights:

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

  // Make the tweens static because they don't change.
  static final Tween<double> _opacityTween = Tween(begin: 0.1, end: 1);
  static final Tween<double> _sizeTween = Tween(begin: 0, end: 300);

  @override
  Widget build(BuildContext context) {
    final animation = listenable as Animation<double>;
    return Center(
      child: Opacity(
        opacity: _opacityTween.evaluate(animation),
        child: Container(
          margin: const EdgeInsets.symmetric(vertical: 10),
          height: _sizeTween.evaluate(animation),
          width: _sizeTween.evaluate(animation),
          child: const FlutterLogo(),
        ),
      ),
    );
  }
}

class LogoApp extends StatefulWidget {
  const LogoApp({super.key});

  @override
  State<LogoApp> createState() => _LogoAppState();
}

class _LogoAppState extends State<LogoApp> with SingleTickerProviderStateMixin {
  late Animation<double> animation;
  late AnimationController controller;

  @override
  void initState() {
    super.initState();
    controller = AnimationController(
      duration: const Duration(seconds: 2),
      vsync: this,
    );
    animation = CurvedAnimation(parent: controller, curve: Curves.easeIn)
      ..addStatusListener((status) {
        if (status == AnimationStatus.completed) {
          controller.reverse();
        } else if (status == AnimationStatus.dismissed) {
          controller.forward();
        }
      });
    controller.forward();
  }

  @override
  Widget build(BuildContext context) => AnimatedLogo(animation: animation);

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

App source: animate5

Next steps

This tutorial gives you a foundation for creating explicit animations in Flutter using tweens, but there are many other animation APIs to explore:

Was this useful?

Content from flutter/website, © The Flutter Authors, used under the Creative Commons Attribution 3.0 License; code samples under the BSD License.