A staggered animation consists of sequential or overlapping animations. The animation might be purely sequential, with one change occurring after the next, or it might partially or completely overlap. When the animation is sequential, the elements are animated sequentially with a slight delay between the start times of each element. This creates a cascading or ripple effect, where the animation appears to move through the elements in stages rather than all at once. Staggered animations are considered a type of micro-interaction because they enhance the user experience by providing subtle, interactive feedback that guides the user through an interface. In Flutter, you can build micro-interactions by crafting subtle animations using either the implicit or explicit animation. For context, implicit animations are designed to be simple and easy to use because the animation details are abstracted away while explicit animations are more suitable for complex animations because they offer complete control of the animation process. This is especially used when you need to have more fine-grained control over the animation. In this article, we will be delving into the concept of micro-interactions, then for a micro-interaction use case, we will use explicit animation to create a staggered animation that animates the children contained in a column widget. What Is the Concept of Micro-Interactions? Great products are products that deliver well on both features and detail. Features bring people to your products, but details keep them. These details are things that make your app stand out from others. With micro-interactions, you can create these details by providing delightful feedback to your users. The concept of micro-interactions is based on the idea that small details can have a big impact on the overall user experience. Micro-interactions can be used to serve essential functions like communicating feedback or the result of an action. Examples of micro-interactions include: Button animations: The button changes color or size when hovered or pressed. Loading indicators: Animations that indicate to a user that a process is in progress. Swipe gestures: Animations that respond to swipe gestures. Navigation transitions: Smooth animations when transitioning between screens. Micro-Interactions in Real-Life Applications Below, we can see real-life applications of micro-interactions, these subtle animations are created in Flutter, to elevate user experience. 👇🏽 Design references for these apps were gotten from Dribbble How To Create Staggered Animation in Flutter In this example, we will be creating a staggered animation that animates the children in a column widget when that page is swiped. This will be built using the explicit animation approach because in this case, we will need to have full control of how we want the animation to run. Here is how the animation will look like when we are completed 👇🏽 Prerequisite To make the most of this tutorial, you should have the following: A basic understanding of how animations are created in Flutter A good grasp of Flutter & Dart fundamentals A code editor, either VScode or Android Studio An emulator or device to build on Once, you’ve checked out all the project’s prerequisites, let’s dive in. First, we will build the main screen that contains these two pages. These two pages will be wrapped in a pageview widget that controls which page is displayed on swipe. Also, at the bottom of the main screen, we have an indicator that shows us the page that we are currently on. class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } } The SmoothPageIndicator used in the code snippet above can be found on pub.dev. Here, the SmoothPageIndicator is used to show the current page in view. You can add the package to your pubspec.yaml like so 👇🏽 dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0 In the two pages, we will have a column widget with several empty card widgets. These empty card widgets will be used to populate the column so that we can fully see and appreciate the staggered animation. We will create the empty card widget as a re-useable component so that we don’t have to build it from scratch at every place we use it. class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } } With all these boilerplate codes out of the way, we will now begin building the animation. First, we will create a new stateful widget which we will call AnimateWidget. It will have several parameters to control how the animation is going to run. class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } With the params of AnimateWidget as seen in the snippet above, we can successfully control: Duration of the animation. The pageController , causing it to trigger the animation when the page is swiped. The amount of horizontal offset of the animated element. Next, in the AnimateWiget, we will define the following: AnimationController: Used to control the animation sequence. Current page: will be used to hold current page data. Timer: Will be used to trigger the animation after some delay; this is what will bring about the staggered animation cascading effect. class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } Breaking down the code snippet above, you’ll notice that: We used AnimationController to control the animation sequence, because of this, we introduced the SingleTickerProviderStateMixin. In the init state method, we initialized the AnimationController, then triggered the animation using animationController.forward, on page entry, and page swipe. We used the Timer to control the triggering of the animation based on the delay. In the dispose method, we cleaned up resources. We used the AutomaticKeepAliveClientMixin to preserve the state of the widget. This is to prevent the disposal of the AnimationController, when a page is swiped and is no longer visible. In the getDelay function, we calculated the delay before the animation was triggered for each element. To achieve this, we divided the duration in milliseconds by 6 and approximated the results, then multiplied it by the position. This is the position (in this case, the index) of the element. In the Build method, we return an AnimatedBuilder. In this animated builder, we will return a widget function called _slideAnimation that returns a Transform.translate widget. In the _slideAnimation function, we have the offsetAnimation function. The offsetAnimation function returns the Animation property which is used in the Transform.translate widget. The Transform.translate widget animates the child widget, using the value from the animation. @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } This is the full code for the AnimateWidget Class 👇🏽 class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } } Next, to use this AnimateWidget class in a column widget, we will create a class with a static method called toStaggeredList that returns a list, In this method, we pass all the needed parameters, including a list children. The children parameter is where we would pass the list of elements that we will be animating. Next, we will map children, wrapping each child with the AnimateWidget. class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); } In the AnimateWidget, we pass in the required parameters to successfully animate each child in the list. Using the AnimateList.toStaggeredList method, we can now implement it on the two pages that we are working on. class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } } In the column widget’s children, we will pass the AnimateList.toStaggeredList and pass the parameters needed, including the widgets that are to be displayed in the column. With this, we have successfully created a staggered animation triggered on swipe. You can check out the full code here. This is our final result: Conclusion We have come to the end of this tutorial. At this point, we covered the concept of micro-interactions & it’s impact on user experience. We also went through the process of building staggered animation of items in a column widget triggered on page swipe. There are so many kinds of micro-interactions you can add to your app to improve the user experience of your project; you can experiment by building more interactive animations in Flutter. If you found this article helpful, you can support it by leaving a like or comment. You can also follow me for more related articles. References Flutter Staggered Animation Package A staggered animation consists of sequential or overlapping animations. The animation might be purely sequential, with one change occurring after the next, or it might partially or completely overlap. When the animation is sequential, the elements are animated sequentially with a slight delay between the start times of each element. This creates a cascading or ripple effect, where the animation appears to move through the elements in stages rather than all at once. Staggered animations are considered a type of micro-interaction because they enhance the user experience by providing subtle, interactive feedback that guides the user through an interface. In Flutter, you can build micro-interactions by crafting subtle animations using either the implicit or explicit animation. For context, implicit animations are designed to be simple and easy to use because the animation details are abstracted away while explicit animations are more suitable for complex animations because they offer complete control of the animation process. This is especially used when you need to have more fine-grained control over the animation. In this article, we will be delving into the concept of micro-interactions, then for a micro-interaction use case, we will use explicit animation to create a staggered animation that animates the children contained in a column widget. What Is the Concept of Micro-Interactions? Great products are products that deliver well on both features and detail. Features bring people to your products, but details keep them. These details are things that make your app stand out from others. With micro-interactions, you can create these details by providing delightful feedback to your users. The concept of micro-interactions is based on the idea that small details can have a big impact on the overall user experience. Micro-interactions can be used to serve essential functions like communicating feedback or the result of an action. Examples of micro-interactions include: Button animations: The button changes color or size when hovered or pressed. Button animations: The button changes color or size when hovered or pressed. Loading indicators: Animations that indicate to a user that a process is in progress. Loading indicators: Animations that indicate to a user that a process is in progress. Swipe gestures: Animations that respond to swipe gestures. Swipe gestures: Animations that respond to swipe gestures. Navigation transitions: Smooth animations when transitioning between screens. Navigation transitions: Smooth animations when transitioning between screens. Micro-Interactions in Real-Life Applications Below, we can see real-life applications of micro-interactions, these subtle animations are created in Flutter, to elevate user experience. 👇🏽 Design references for these apps were gotten from Dribbble Design references for these apps were gotten from Dribbble How To Create Staggered Animation in Flutter In this example, we will be creating a staggered animation that animates the children in a column widget when that page is swiped. This will be built using the explicit animation approach because in this case, we will need to have full control of how we want the animation to run. Here is how the animation will look like when we are completed 👇🏽 Prerequisite To make the most of this tutorial, you should have the following: A basic understanding of how animations are created in Flutter A good grasp of Flutter & Dart fundamentals A code editor, either VScode or Android Studio An emulator or device to build on A basic understanding of how animations are created in Flutter A good grasp of Flutter & Dart fundamentals A code editor, either VScode or Android Studio An emulator or device to build on Once, you’ve checked out all the project’s prerequisites, let’s dive in. First, we will build the main screen that contains these two pages. These two pages will be wrapped in a pageview widget that controls which page is displayed on swipe. Also, at the bottom of the main screen, we have an indicator that shows us the page that we are currently on. class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } } class MainScreen extends StatefulWidget { const MainScreen({super.key}); @override State<MainScreen> createState() => _MainScreenState(); } class _MainScreenState extends State<MainScreen>{ final controller = PageController(keepPage: true); @override Widget build(BuildContext context) { return Scaffold( body: SafeArea( child: PageView( controller: controller, children: [ Page1(pageController: controller,), Page2(pageController: controller,), ], ), ), bottomNavigationBar: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 10), height: 30, child: SmoothPageIndicator( controller: controller, count: 2, effect: const JumpingDotEffect( dotHeight: 10, dotWidth: 10, activeDotColor: Colors.grey, dotColor: Colors.black12, ), ), ) ); } } The SmoothPageIndicator used in the code snippet above can be found on pub.dev . Here, the SmoothPageIndicator is used to show the current page in view. You can add the package to your pubspec.yaml like so 👇🏽 SmoothPageIndicator pub.dev SmoothPageIndicator pubspec.yaml dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0 dependencies: flutter: sdk: flutter smooth_page_indicator: ^1.1.0 In the two pages, we will have a column widget with several empty card widgets. These empty card widgets will be used to populate the column so that we can fully see and appreciate the staggered animation. We will create the empty card widget as a re-useable component so that we don’t have to build it from scratch at every place we use it. class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } } class EmptyCard extends StatelessWidget { const EmptyCard({super.key, required this.width, required this.height}); final double width; final double height; @override Widget build(BuildContext context) { return Container( height: height, width: width, decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), color: Colors.blue.shade200, boxShadow: const [ BoxShadow( color: Colors.black12, blurRadius: 4, offset: Offset(0,4), ) ] ), ); } } With all these boilerplate codes out of the way, we will now begin building the animation. First, we will create a new stateful widget which we will call AnimateWidget . It will have several parameters to control how the animation is going to run. AnimateWidget class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } With the params of AnimateWidget as seen in the snippet above, we can successfully control: AnimateWidget Duration of the animation. The pageController , causing it to trigger the animation when the page is swiped. The amount of horizontal offset of the animated element. Duration of the animation. The pageController , causing it to trigger the animation when the page is swiped. pageController The amount of horizontal offset of the animated element. Next, in the AnimateWiget , we will define the following: AnimateWiget AnimationController: Used to control the animation sequence. Current page: will be used to hold current page data. Timer: Will be used to trigger the animation after some delay; this is what will bring about the staggered animation cascading effect. class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } AnimationController: Used to control the animation sequence. AnimationController : Used to control the animation sequence. AnimationController Current page: will be used to hold current page data. Current page: will be used to hold current page data. Timer: Will be used to trigger the animation after some delay; this is what will bring about the staggered animation cascading effect. class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } Timer: Will be used to trigger the animation after some delay; this is what will bring about the staggered animation cascading effect. class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } Breaking down the code snippet above, you’ll notice that: We used AnimationController to control the animation sequence, because of this, we introduced the SingleTickerProviderStateMixin. We used AnimationController to control the animation sequence, because of this, we introduced the SingleTickerProviderStateMixin . AnimationController SingleTickerProviderStateMixin In the init state method, we initialized the AnimationController, then triggered the animation using animationController.forward, on page entry, and page swipe. In the init state method, we initialized the AnimationController , then triggered the animation using animationController.forward , on page entry, and page swipe. init state AnimationController animationController.forward We used the Timer to control the triggering of the animation based on the delay. We used the Timer to control the triggering of the animation based on the delay. In the dispose method, we cleaned up resources. In the dispose method, we cleaned up resources. dispose We used the AutomaticKeepAliveClientMixin to preserve the state of the widget. This is to prevent the disposal of the AnimationController, when a page is swiped and is no longer visible. We used the AutomaticKeepAliveClientMixin to preserve the state of the widget. This is to prevent the disposal of the AnimationController , when a page is swiped and is no longer visible. AutomaticKeepAliveClientMixin AnimationController In the getDelay function, we calculated the delay before the animation was triggered for each element. To achieve this, we divided the duration in milliseconds by 6 and approximated the results, then multiplied it by the position. This is the position (in this case, the index) of the element. In the getDelay function, we calculated the delay before the animation was triggered for each element. To achieve this, we divided the duration in milliseconds by 6 and approximated the results, then multiplied it by the position. This is the position (in this case, the index) of the element. In the getDelay function, we calculated the delay before the animation was triggered for each element. To achieve this, we divided the duration in milliseconds by 6 and approximated the results, then multiplied it by the position. This is the position (in this case, the index) of the element. getDelay In the Build method, we return an AnimatedBuilder . In this animated builder, we will return a widget function called _slideAnimation that returns a Transform.translate widget. In the _slideAnimation function, we have the offsetAnimation function. AnimatedBuilder _slideAnimation Transform.translate _slideAnimation offsetAnimation The offsetAnimation function returns the Animation property which is used in the Transform.translate widget. The Transform.translate widget animates the child widget, using the value from the animation. offsetAnimation Transform.translate Transform.translate @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } This is the full code for the AnimateWidget Class 👇🏽 AnimateWidget class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } } class AnimateWidget extends StatefulWidget { const AnimateWidget({super.key, required this.duration, required this.position, required this.horizontalOffset, required this.child, required this.controller, }); final Duration duration; final int position; final double? horizontalOffset; final Widget child; final PageController controller; @override State<AnimateWidget> createState() => _AnimateWidgetState(); } class _AnimateWidgetState extends State<AnimateWidget> with AutomaticKeepAliveClientMixin, SingleTickerProviderStateMixin{ @override bool get wantKeepAlive => true; late AnimationController animationController; int currentPage = 0; Timer? _timer; @override void initState() { super.initState(); animationController = AnimationController(vsync: this, duration: widget.duration); _timer = Timer(getDelay(), animationController.forward); widget.controller.addListener(() { currentPage = widget.controller.page!.round(); if(currentPage == widget.controller.page){ _timer = Timer(getDelay(), animationController.forward); } }); } @override void dispose() { _timer?.cancel(); animationController.dispose(); super.dispose(); } Duration getDelay(){ var delayInMilliSec = widget.duration.inMilliseconds ~/ 6; int getStaggeredListDuration(){ return widget.position * delayInMilliSec; } return Duration(milliseconds: getStaggeredListDuration()); } @override Widget build(BuildContext context) { super.build(context); return AnimatedBuilder( animation: animationController, builder: (context, child){ return _slideAnimation(animationController); } ); } Widget _slideAnimation(Animation<double> animationController){ Animation<double> offsetAnimation(double offset, Animation<double> animationController) { return Tween<double>(begin: offset, end: 0.0).animate( CurvedAnimation( parent: animationController, curve: const Interval(0.0, 1.0, curve: Curves.ease), ), ); } return Transform.translate( offset: Offset( widget.horizontalOffset == 0.0 ? 0.0 : offsetAnimation(widget.horizontalOffset!, animationController).value, 0.0, ), child: widget.child, ); } } Next, to use this AnimateWidget class in a column widget, we will create a class with a static method called toStaggeredList that returns a list, In this method, we pass all the needed parameters, including a list children . The children parameter is where we would pass the list of elements that we will be animating. AnimateWidget toStaggeredList children children Next, we will map children, wrapping each child with the AnimateWidget . AnimateWidget class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); } class AnimateList{ static List<Widget>toStaggeredList({ required Duration duration, double? horizontalOffset, required PageController controller, required List<Widget>children, })=> children .asMap() .map((index, widget){ return MapEntry( index, AnimateWidget( duration: duration, position: index, horizontalOffset: horizontalOffset, controller: controller, child: widget, ) ); }) .values .toList(); } In the AnimateWidget , we pass in the required parameters to successfully animate each child in the list. Using the AnimateList.toStaggeredList method, we can now implement it on the two pages that we are working on. AnimateWidget AnimateList.toStaggeredList class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } } class Page1 extends StatelessWidget { const Page1({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), const EmptyCard(width: 250, height: 50,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 180, height: 80,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 270, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), EmptyCard(height: 50, width: 70), ], ), ), ], ), ), ); } } class Page2 extends StatelessWidget { const Page2({super.key, required this.pageController}); final PageController pageController; @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: AnimateList.toStaggeredList( duration: const Duration(milliseconds: 375), controller: pageController, horizontalOffset: MediaQuery.of(context).size.width / 2, children: [ const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 200, height: 50,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), const EmptyCard(width: 220, height: 70,), const Padding( padding: EdgeInsets.only(top: 20), child: EmptyCard(width: 300, height: 70,), ), const Padding( padding: EdgeInsets.symmetric(vertical: 20), child: Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ EmptyCard(height: 70, width: 70), EmptyCard(height: 70, width: 70), ], ), ), ], ), ), ); } } In the column widget’s children, we will pass the AnimateList.toStaggeredList and pass the parameters needed, including the widgets that are to be displayed in the column. With this, we have successfully created a staggered animation triggered on swipe. You can check out the full code here . AnimateList.toStaggeredList here This is our final result: Conclusion We have come to the end of this tutorial. At this point, we covered the concept of micro-interactions & it’s impact on user experience. We also went through the process of building staggered animation of items in a column widget triggered on page swipe. There are so many kinds of micro-interactions you can add to your app to improve the user experience of your project; you can experiment by building more interactive animations in Flutter. If you found this article helpful, you can support it by leaving a like or comment. You can also follow me for more related articles. References Flutter Staggered Animation Package Flutter Staggered Animation Package