Flutter Animations: A Practical Guide (with Copy-Paste Examples)

Animations make your app feel alive, guide attention, and communicate state changes. In Flutter, you get two broad styles:

  • Implicit animations – easy, one-liner style; great for simple property changes.

  • Explicit animations – full control via AnimationController; ideal for sequences, custom curves, and orchestrated motion.

Below is a hands-on tour with small, focused examples you can paste straight into your project.


1) Implicit Animations: Zero-Boilerplate Polish

Implicit widgets animate from their old value to the new value whenever they rebuild.

1.1 AnimatedContainer (size, color, radius)

import 'package:flutter/material.dart'; void main() => runApp(const AnimatedContainerDemo()); class AnimatedContainerDemo extends StatelessWidget { const AnimatedContainerDemo({super.key}); @override Widget build(BuildContext context) { return MaterialApp( home: const _Page(), debugShowCheckedModeBanner: false, ); } } class _Page extends StatefulWidget { const _Page({super.key}); @override State<_Page> createState() => _PageState(); } class _PageState extends State<_Page> { bool toggled = false; @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: const Text('AnimatedContainer')), body: Center( child: AnimatedContainer( duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, height: toggled ? 180 : 90, width: toggled ? 180 : 90, decoration: BoxDecoration( color: toggled ? Colors.blue : Colors.orange, borderRadius: BorderRadius.circular(toggled ? 24 : 4), ), child: const Icon(Icons.flutter_dash, size: 48, color: Colors.white), ), ), floatingActionButton: FloatingActionButton.extended( onPressed: () => setState(() => toggled = !toggled), label: const Text('Animate'), icon: const Icon(Icons.play_arrow), ), ); } }

1.2 AnimatedOpacity (fade things in/out)

class FadeInOut extends StatefulWidget { const FadeInOut({super.key}); @override State<FadeInOut> createState() => _FadeInOutState(); } class _FadeInOutState extends State<FadeInOut> { bool visible = true; @override Widget build(BuildContext context) { return Column( children: [ AnimatedOpacity( duration: const Duration(milliseconds: 400), opacity: visible ? 1 : 0, child: const FlutterLogo(size: 100), ), const SizedBox(height: 16), ElevatedButton( onPressed: () => setState(() => visible = !visible), child: Text(visible ? 'Hide' : 'Show'), ), ], ); } }

1.3 AnimatedSwitcher (cross-fade / scale between children)

class CounterSwitcher extends StatefulWidget { const CounterSwitcher({super.key}); @override State<CounterSwitcher> createState() => _CounterSwitcherState(); } class _CounterSwitcherState extends State<CounterSwitcher> { int count = 0; @override Widget build(BuildContext context) { return Column( children: [ AnimatedSwitcher( duration: const Duration(milliseconds: 350), transitionBuilder: (child, anim) => ScaleTransition(scale: anim, child: child), child: Text( '$count', key: ValueKey(count), // IMPORTANT: unique key per child style: const TextStyle(fontSize: 64, fontWeight: FontWeight.bold), ), ), ElevatedButton( onPressed: () => setState(() => count++), child: const Text('Increment'), ), ], ); } }

2) Explicit Animations: Full Control

Use an AnimationController when you need to:

  • coordinate multiple properties

  • run sequences (staggered)

  • loop / reverse / fling

  • pause/resume, or hook into status callbacks

2.1 Scale + Opacity with AnimationController

class ExplicitAnimDemo extends StatefulWidget { const ExplicitAnimDemo({super.key}); @override State<ExplicitAnimDemo> createState() => _ExplicitAnimDemoState(); } class _ExplicitAnimDemoState extends State<ExplicitAnimDemo> with SingleTickerProviderStateMixin { late final AnimationController ctrl; late final Animation<double> scale; late final Animation<double> opacity; @override void initState() { super.initState(); ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 900), ); // Use CurvedAnimation for easing: final curved = CurvedAnimation(parent: ctrl, curve: Curves.easeInOut); scale = Tween(begin: 0.8, end: 1.0).animate(curved); opacity = Tween(begin: 0.0, end: 1.0).animate(curved); ctrl.forward(); } @override void dispose() { ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Column( children: [ const SizedBox(height: 24), ScaleTransition( scale: scale, child: FadeTransition( opacity: opacity, child: Container( padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.indigo.shade50, borderRadius: BorderRadius.circular(16), ), child: const Text( 'Hello, Animation!', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600), ), ), ), ), const SizedBox(height: 24), Wrap(spacing: 12, children: [ ElevatedButton(onPressed: ctrl.forward, child: const Text('Forward')), ElevatedButton(onPressed: ctrl.reverse, child: const Text('Reverse')), ElevatedButton(onPressed: ctrl.repeat, child: const Text('Loop')), ElevatedButton(onPressed: ctrl.stop, child: const Text('Stop')), ]), ], ); } }

3) Staggered Animation (Sequence with Intervals)

Staggering lets you chain parts of a motion for storytelling.

class StaggeredCard extends StatefulWidget { const StaggeredCard({super.key}); @override State<StaggeredCard> createState() => _StaggeredCardState(); } class _StaggeredCardState extends State<StaggeredCard> with SingleTickerProviderStateMixin { late final AnimationController ctrl; late final Animation<double> slideUp; // 0.0 -> 1.0 late final Animation<double> fadeIn; // 0.4 -> 1.0 late final Animation<double> scaleIn; // 0.6 -> 1.0 @override void initState() { super.initState(); ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 1200), ); slideUp = Tween(begin: 40.0, end: 0.0).animate( CurvedAnimation(parent: ctrl, curve: const Interval(0.0, 0.5, curve: Curves.easeOut)), ); fadeIn = Tween(begin: 0.0, end: 1.0).animate( CurvedAnimation(parent: ctrl, curve: const Interval(0.2, 0.7, curve: Curves.easeIn)), ); scaleIn = Tween(begin: 0.95, end: 1.0).animate( CurvedAnimation(parent: ctrl, curve: const Interval(0.6, 1.0, curve: Curves.easeOutBack)), ); ctrl.forward(); } @override void dispose() { ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: ctrl, builder: (_, __) { return Opacity( opacity: fadeIn.value, child: Transform.translate( offset: Offset(0, slideUp.value), child: Transform.scale( scale: scaleIn.value, child: Container( margin: const EdgeInsets.all(24), padding: const EdgeInsets.all(24), decoration: BoxDecoration( color: Colors.teal.shade50, borderRadius: BorderRadius.circular(20), ), child: const ListTile( leading: Icon(Icons.check_circle, size: 36), title: Text('Staggered Motion'), subtitle: Text('Slide → Fade → Scale'), ), ), ), ), ); }, ); } }

4) Page-to-Page Delight: Hero Transition

Hero links matching widgets across routes by tag.

class HeroListPage extends StatelessWidget { const HeroListPage({super.key}); @override Widget build(BuildContext context) { final images = [ 'https://picsum.photos/seed/1/400', 'https://picsum.photos/seed/2/400', 'https://picsum.photos/seed/3/400', ]; return Scaffold( appBar: AppBar(title: const Text('Hero List')), body: ListView.separated( padding: const EdgeInsets.all(16), itemBuilder: (_, i) => ListTile( leading: Hero( tag: 'img-$i', child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network(images[i], width: 56, height: 56, fit: BoxFit.cover), ), ), title: Text('Item $i'), onTap: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => HeroDetailPage(tag: 'img-$i', url: images[i])), ), ), separatorBuilder: (_, __) => const Divider(), itemCount: images.length, ), ); } } class HeroDetailPage extends StatelessWidget { final String tag; final String url; const HeroDetailPage({super.key, required this.tag, required this.url}); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(), body: Center( child: Hero( tag: tag, child: ClipRRect( borderRadius: BorderRadius.circular(16), child: Image.network(url, width: 300, height: 300, fit: BoxFit.cover), ), ), ), ); } }

5) List & State Changes: AnimatedList and Small Wins

5.1 AnimatedList (insert/remove with animation)

class TodoAnimatedList extends StatefulWidget { const TodoAnimatedList({super.key}); @override State<TodoAnimatedList> createState() => _TodoAnimatedListState(); } class _TodoAnimatedListState extends State<TodoAnimatedList> { final GlobalKey<AnimatedListState> _key = GlobalKey(); final items = <String>['Buy milk', 'Email client', 'Push code']; void _add() { items.insert(0, 'New task #${items.length + 1}'); _key.currentState?.insertItem(0, duration: const Duration(milliseconds: 300)); } void _remove(int index) { final removed = items.removeAt(index); _key.currentState?.removeItem( index, (context, animation) => SizeTransition( sizeFactor: animation, child: ListTile(title: Text(removed)), ), duration: const Duration(milliseconds: 300), ); } @override Widget build(BuildContext context) { return Column( children: [ ElevatedButton.icon(onPressed: _add, icon: const Icon(Icons.add), label: const Text('Add')), Expanded( child: AnimatedList( key: _key, initialItemCount: items.length, itemBuilder: (context, index, animation) { return SizeTransition( sizeFactor: animation, child: ListTile( title: Text(items[index]), trailing: IconButton( icon: const Icon(Icons.delete), onPressed: () => _remove(index), ), ), ); }, ), ), ], ); } }

5.2 AnimatedIcon (built-in menu morph)

class AnimatedMenuIcon extends StatefulWidget { const AnimatedMenuIcon({super.key}); @override State<AnimatedMenuIcon> createState() => _AnimatedMenuIconState(); } class _AnimatedMenuIconState extends State<AnimatedMenuIcon> with SingleTickerProviderStateMixin { late final AnimationController ctrl; @override void initState() { super.initState(); ctrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 350), ); } @override void dispose() { ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return IconButton( iconSize: 36, icon: AnimatedIcon(icon: AnimatedIcons.menu_close, progress: ctrl), onPressed: () { if (ctrl.isDismissed) { ctrl.forward(); } else { ctrl.reverse(); } }, ); } }

6) CustomPainter + Animation (for bespoke effects)

When you need something unique (progress rings, waves, charts), drive a CustomPainter with an animation.

class PulsingCircle extends StatefulWidget { const PulsingCircle({super.key}); @override State<PulsingCircle> createState() => _PulsingCircleState(); } class _PulsingCircleState extends State<PulsingCircle> with SingleTickerProviderStateMixin { late final AnimationController ctrl; @override void initState() { super.initState(); ctrl = AnimationController( vsync: this, lowerBound: 0.8, upperBound: 1.2, duration: const Duration(seconds: 1), )..repeat(reverse: true); } @override void dispose() { ctrl.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return AnimatedBuilder( animation: ctrl, builder: (_, __) => CustomPaint( painter: _CirclePainter(scale: ctrl.value), size: const Size(160, 160), ), ); } } class _CirclePainter extends CustomPainter { final double scale; _CirclePainter({required this.scale}); @override void paint(Canvas canvas, Size size) { final center = size.center(Offset.zero); final radius = (size.shortestSide / 2) * scale; final paint = Paint()..style = PaintingStyle.stroke..strokeWidth = 8; paint.color = Colors.purple; canvas.drawCircle(center, radius, paint); } @override bool shouldRepaint(covariant _CirclePainter old) => old.scale != scale; }

7) Putting It Together: A Mini Demo App with Tabs

Paste this into main.dart to browse most examples quickly:

import 'package:flutter/material.dart'; // Import or paste the widgets above in the same file: // FadeInOut, CounterSwitcher, ExplicitAnimDemo, StaggeredCard, // TodoAnimatedList, AnimatedMenuIcon, PulsingCircle void main() => runApp(const AnimPlayground()); class AnimPlayground extends StatelessWidget { const AnimPlayground({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Flutter Animations', debugShowCheckedModeBanner: false, home: DefaultTabController( length: 6, child: Scaffold( appBar: AppBar( title: const Text('Flutter Animations'), bottom: const TabBar(isScrollable: true, tabs: [ Tab(text: 'Implicit'), Tab(text: 'Switcher'), Tab(text: 'Explicit'), Tab(text: 'Stagger'), Tab(text: 'List'), Tab(text: 'Custom'), ]), actions: const [Padding(padding: EdgeInsets.only(right: 8), child: AnimatedMenuIcon())], ), body: const TabBarView(children: [ // Implicit tab combines two quick demos: _ImplicitTab(), CounterSwitcher(), ExplicitAnimDemo(), StaggeredCard(), TodoAnimatedList(), Center(child: PulsingCircle()), ]), ), ), ); } } class _ImplicitTab extends StatelessWidget { const _ImplicitTab({super.key}); @override Widget build(BuildContext context) { return ListView( padding: const EdgeInsets.all(16), children: const [ Text('AnimatedOpacity', style: TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 8), FadeInOut(), Divider(height: 32), Text('AnimatedContainer', style: TextStyle(fontWeight: FontWeight.bold)), SizedBox(height: 8), SizedBox(height: 260, child: _EmbeddedAnimatedContainer()), ], ); } } class _EmbeddedAnimatedContainer extends StatefulWidget { const _EmbeddedAnimatedContainer({super.key}); @override State<_EmbeddedAnimatedContainer> createState() => _EmbeddedAnimatedContainerState(); } class _EmbeddedAnimatedContainerState extends State<_EmbeddedAnimatedContainer> { bool toggled = false; @override Widget build(BuildContext context) { return Column( children: [ AnimatedContainer( duration: const Duration(milliseconds: 500), curve: Curves.easeInOut, height: toggled ? 160 : 90, width: toggled ? 160 : 90, decoration: BoxDecoration( color: toggled ? Colors.blue : Colors.orange, borderRadius: BorderRadius.circular(toggled ? 24 : 4), ), ), const SizedBox(height: 12), ElevatedButton( onPressed: () => setState(() => toggled = !toggled), child: const Text('Animate'), ), ], ); } }

Tip: If you split files, ensure each widget is imported or kept in the same file for a quick demo.


8) Performance & UX Tips

  • Prefer implicit animations for simple property changes. They’re cheap and expressive.

  • Repaint boundaries: wrap complex animated children with RepaintBoundary when they don’t need to repaint ancestors.

  • Short durations (150–400 ms) feel snappy for micro-interactions. Longer (600–1200 ms) for staged/staggered onboarding.

  • Use curves (Curves.easeInOut, easeOutCubic, decelerate) to match the feeling of weight and momentum.

  • Avoid layout thrash: prefer Transform or Opacity over repeated expensive layout changes for high-frequency animations.

  • Dispose controllers in dispose() to prevent leaks.

  • Test: For critical flows, assert animation end states in widget tests (e.g., pump frames with pump(const Duration(...))).


9) Quick Checklist When Adding Motion

  • What’s the purpose? (draw attention, confirm action, connect screens)

  • Is the duration appropriate for the context?

  • Does the motion communicate state (e.g., success/error) clearly?

  • Does it respect accessibility (not overly flashy; consider reduced motion settings)?

  • Are we animating the right properties (transform/opacity vs layout) for smoothness?