Animations

Flutter Animations Masterclass: From AnimationController to Production-Ready Motion

Muhammad Shakil Muhammad Shakil
Mar 20, 2026
23 min read
Flutter Animations Masterclass: From AnimationController to Production-Ready Motion
Back to Blog

Animations are the difference between an app that feels professional and one that feels like a homework project. But after reviewing 200+ Flutter codebases at Flutter Studio, the most common animation problem isn't missing motion — it's the wrong kind. Developers slap AnimatedContainer on everything, wonder why their complex sequences stutter, then blame Flutter's animation system instead of learning how it actually works.

This masterclass takes you from the render pipeline up. You'll understand why each animation approach exists, when each one is the right tool, and how to build production-grade motion that runs at a locked 60fps on mid-range devices. Every code example is from a shipped app. Every performance number is from real device profiling.

📋 Prerequisites

This guide assumes solid Dart fundamentals and familiarity with Flutter's widget lifecycle (initState, dispose, setState). For higher-level UX transition patterns, see our companion guide on building stunning UI transitions. For state management integration with animations, see BLoC vs Riverpod comparison.

How Flutter Animations Actually Work Under the Hood

Before writing a single animation, you need to understand what happens at the framework level. Flutter's animation system has four layers, and every animation you build combines them (the official Flutter animations overview covers the full picture):

When you call controller.forward(), the Ticker fires on every frame, the controller generates a new value, that value passes through the Tween and Curve, and the framework calls setState (or the AnimatedBuilder callback) to rebuild the widget with the new value. The entire pipeline happens in under 16ms per frame on any modern device.

// The fundamental animation pipeline visualized
// Ticker (vsync) → AnimationController (0.0→1.0) → CurvedAnimation → Tween → Widget rebuild

class _PipelineDemoState extends State<PipelineDemo>
 with SingleTickerProviderStateMixin {
 late final AnimationController _controller;
 late final Animation<double> _fadeAnimation;
 late final Animation<Offset> _slideAnimation;

 @override
 void initState() {
 super.initState();
 // Layer 1 & 2: Ticker + Controller
 _controller = AnimationController(
 duration: const Duration(milliseconds: 800),
 vsync: this, // SingleTickerProviderStateMixin provides the Ticker
 );

 // Layer 3 & 4: Curve + Tween for fade
 _fadeAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
 CurvedAnimation(parent: _controller, curve: Curves.easeOut),
 );

 // Layer 3 & 4: Curve + Tween for slide
 _slideAnimation = Tween<Offset>(
 begin: const Offset(0.0, 0.3),
 end: Offset.zero,
 ).animate(
 CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
 );

 _controller.forward();
 }

 @override
 void dispose() {
 _controller.dispose(); // Always dispose — prevents memory leaks
 super.dispose();
 }

 @override
 Widget build(BuildContext context) {
 return AnimatedBuilder(
 animation: _controller, // Rebuilds on every tick
 builder: (context, child) {
 return Opacity(
 opacity: _fadeAnimation.value,
 child: FractionalTranslation(
 translation: _slideAnimation.value,
 child: child, // Static child — not rebuilt
 ),
 );
 },
 child: const MyExpensiveWidget(), // Passed as child for performance
 );
 }
}

Notice the child parameter in AnimatedBuilder. This is the single most important animation performance pattern in Flutter. The child widget is built once and reused on every frame. Only the Opacity and FractionalTranslation wrappers are rebuilt. Skip this pattern and you're rebuilding your entire widget tree 60 times per second.

Implicit Animations — The Easy Win

Implicit animations are Flutter's "set it and forget it" approach. You change a property value, and the framework handles the animation automatically. No AnimationController, no Ticker, no dispose. Just change the value and the widget animates to the new value smoothly. The implicit animations guide covers the fundamentals.

Flutter provides implicit animation widgets for nearly every visual property:

// Real example: Expandable product card with implicit animations
class ExpandableProductCard extends StatefulWidget {
 final Product product;
 const ExpandableProductCard({super.key, required this.product});

 @override
 State<ExpandableProductCard> createState() => _ExpandableProductCardState();
}

class _ExpandableProductCardState extends State<ExpandableProductCard> {
 bool _isExpanded = false;

 @override
 Widget build(BuildContext context) {
 return GestureDetector(
 onTap: () => setState(() => _isExpanded = !_isExpanded),
 child: AnimatedContainer(
 duration: const Duration(milliseconds: 400),
 curve: Curves.easeInOutCubic,
 padding: EdgeInsets.all(_isExpanded ? 24.0 : 16.0),
 decoration: BoxDecoration(
 color: _isExpanded
 ? Theme.of(context).colorScheme.primaryContainer
 : Theme.of(context).colorScheme.surface,
 borderRadius: BorderRadius.circular(_isExpanded ? 20.0 : 12.0),
 boxShadow: [
 BoxShadow(
 color: Colors.black.withValues(alpha: _isExpanded ? 0.15 : 0.05),
 blurRadius: _isExpanded ? 20.0 : 8.0,
 offset: Offset(0, _isExpanded ? 8.0 : 2.0),
 ),
 ],
 ),
 child: Column(
 crossAxisAlignment: CrossAxisAlignment.start,
 children: [
 Text(widget.product.name, style: Theme.of(context).textTheme.titleMedium),
 AnimatedCrossFade(
 firstChild: const SizedBox.shrink(),
 secondChild: Padding(
 padding: const EdgeInsets.only(top: 12.0),
 child: Text(
 widget.product.description,
 style: Theme.of(context).textTheme.bodyMedium,
 ),
 ),
 crossFadeState: _isExpanded
 ? CrossFadeState.showSecond
 : CrossFadeState.showFirst,
 duration: const Duration(milliseconds: 300),
 ),
 ],
 ),
 ),
 );
 }
}

This card smoothly animates padding, color, border radius, shadow, and content expansion — all with zero manual animation code. The AnimatedContainer handles the property interpolation, and AnimatedCrossFade handles the content reveal. Use implicit animations as your default choice and only reach for explicit animations when you hit a limitation.

💡 When implicit isn't enough

Implicit animations can't loop, can't be driven by gestures, can't be sequenced with other animations, and can't be paused or reversed programmatically. If you need any of these, move to explicit animations with AnimationController.

Explicit Animations — Full Control with AnimationController

Explicit animations give you complete control over timing, playback, and coordination. The trade-off is more code: you manage the AnimationController lifecycle yourself. Here's a real notification badge animation from a messaging app — it bounces in, pulses, then settles.

class NotificationBadge extends StatefulWidget {
 final int count;
 const NotificationBadge({super.key, required this.count});

 @override
 State<NotificationBadge> createState() => _NotificationBadgeState();
}

class _NotificationBadgeState extends State<NotificationBadge>
 with SingleTickerProviderStateMixin {
 late final AnimationController _controller;
 late final Animation<double> _scaleAnimation;
 late final Animation<double> _opacityAnimation;

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

 // Scale: overshoot bounce effect
 _scaleAnimation = TweenSequence<double>([
 TweenSequenceItem(tween: Tween(begin: 0.0, end: 1.3), weight: 40),
 TweenSequenceItem(tween: Tween(begin: 1.3, end: 0.9), weight: 25),
 TweenSequenceItem(tween: Tween(begin: 0.9, end: 1.0), weight: 35),
 ]).animate(CurvedAnimation(
 parent: _controller,
 curve: Curves.easeOut,
 ));

 // Opacity: quick fade in during first 30%
 _opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
 CurvedAnimation(
 parent: _controller,
 curve: const Interval(0.0, 0.3, curve: Curves.easeIn),
 ),
 );

 _controller.forward();
 }

 @override
 void didUpdateWidget(NotificationBadge oldWidget) {
 super.didUpdateWidget(oldWidget);
 if (oldWidget.count != widget.count && widget.count > 0) {
 _controller.forward(from: 0.0); // Re-trigger bounce on count change
 }
 }

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

 @override
 Widget build(BuildContext context) {
 if (widget.count == 0) return const SizedBox.shrink();
 return AnimatedBuilder(
 animation: _controller,
 builder: (context, child) {
 return Transform.scale(
 scale: _scaleAnimation.value,
 child: Opacity(
 opacity: _opacityAnimation.value,
 child: child,
 ),
 );
 },
 child: Container(
 padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
 decoration: BoxDecoration(
 color: Colors.red,
 borderRadius: BorderRadius.circular(12),
 ),
 child: Text(
 '${widget.count}',
 style: const TextStyle(
 color: Colors.white,
 fontSize: 12,
 fontWeight: FontWeight.bold,
 ),
 ),
 ),
 );
 }
}

Key techniques here: TweenSequence creates the bounce overshoot effect with three phases. Interval makes the opacity animate only during the first 30% of the overall duration. didUpdateWidget re-triggers the animation when the count changes, giving tactile feedback for new notifications. The static Container child is built once and cached.

Tweens & Curves — Mapping Values and Easing

Tweens and curves are the seasoning that makes animations feel alive instead of mechanical. Choosing the right combination is what separates amateur animations from professional motion design.

Common Tween types:

Curve guidelines from our design system:

// Custom curve: spring-like overshoot with damping
class SpringCurve extends Curve {
 final double damping;
 final double frequency;

 const SpringCurve({this.damping = 0.6, this.frequency = 3.5});

 @override
 double transformInternal(double t) {
 // Damped spring oscillation formula
 return 1 - (1 - t) * math.cos(t * frequency * math.pi) *
 math.exp(-damping * t * 10);
 }
}

// Usage: Custom spring for a card flip animation
final _flipAnimation = Tween<double>(
 begin: 0.0,
 end: math.pi,
).animate(CurvedAnimation(
 parent: _controller,
 curve: const SpringCurve(damping: 0.5, frequency: 2.0),
));

Custom curves let you create brand-specific motion. We've built unique spring curves for three different client apps where the animation timing is part of the brand identity. Material Design uses specific curves (Curves.easeInOutCubicEmphasized for M3), but your app's personality can come through custom easing.

Staggered Animations — Orchestrating Complex Motion

Staggered animations create the "cascade" effect where multiple elements animate in sequence, each starting slightly after the previous one. This is the secret behind polished onboarding screens, list reveals, and dashboard load sequences.

// Real example: Onboarding screen with staggered entrance
class OnboardingScreen extends StatefulWidget {
 const OnboardingScreen({super.key});

 @override
 State<OnboardingScreen> createState() => _OnboardingScreenState();
}

class _OnboardingScreenState extends State<OnboardingScreen>
 with SingleTickerProviderStateMixin {
 late final AnimationController _controller;
 late final Animation<double> _iconScale;
 late final Animation<double> _titleSlide;
 late final Animation<double> _descFade;
 late final Animation<Offset> _buttonSlide;

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

 // Icon: scale in from 0.0 to 1.0, runs from 0% to 30%
 _iconScale = Tween<double>(begin: 0.0, end: 1.0).animate(
 CurvedAnimation(
 parent: _controller,
 curve: const Interval(0.0, 0.3, curve: Curves.elasticOut),
 ),
 );

 // Title: slide up, runs from 15% to 50%
 _titleSlide = Tween<double>(begin: 30.0, end: 0.0).animate(
 CurvedAnimation(
 parent: _controller,
 curve: const Interval(0.15, 0.5, curve: Curves.easeOutCubic),
 ),
 );

 // Description: fade in, runs from 35% to 65%
 _descFade = Tween<double>(begin: 0.0, end: 1.0).animate(
 CurvedAnimation(
 parent: _controller,
 curve: const Interval(0.35, 0.65, curve: Curves.easeIn),
 ),
 );

 // Button: slide up from bottom, runs from 55% to 100%
 _buttonSlide = Tween<Offset>(
 begin: const Offset(0.0, 1.5),
 end: Offset.zero,
 ).animate(
 CurvedAnimation(
 parent: _controller,
 curve: const Interval(0.55, 1.0, curve: Curves.easeOutCubic),
 ),
 );

 _controller.forward();
 }

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

 @override
 Widget build(BuildContext context) {
 return AnimatedBuilder(
 animation: _controller,
 builder: (context, child) {
 return Column(
 mainAxisAlignment: MainAxisAlignment.center,
 children: [
 Transform.scale(
 scale: _iconScale.value,
 child: const Icon(Icons.rocket_launch, size: 80),
 ),
 const SizedBox(height: 32),
 Transform.translate(
 offset: Offset(0, _titleSlide.value),
 child: Text(
 'Welcome to AppName',
 style: Theme.of(context).textTheme.headlineMedium,
 ),
 ),
 const SizedBox(height: 16),
 Opacity(
 opacity: _descFade.value,
 child: Text(
 'The best way to get things done.',
 style: Theme.of(context).textTheme.bodyLarge,
 ),
 ),
 const SizedBox(height: 48),
 SlideTransition(
 position: _buttonSlide,
 child: ElevatedButton(
 onPressed: () {},
 child: const Text('Get Started'),
 ),
 ),
 ],
 );
 },
 );
 }
}

The critical concept: all four animations share one AnimationController. The Interval curves determine when each animation is active within the controller's total duration. The icon starts first (0.0–0.3), overlaps with the title (0.15–0.5), which overlaps with the description (0.35–0.65), which overlaps with the button (0.55–1.0). The overlapping Intervals create that smooth cascade effect.

🎯 Staggered list items

For animating a dynamic number of list items (not fixed like the onboarding example), don't create individual AnimationControllers per item. Instead, use a single controller with calculated intervals based on item index: Interval(index * 0.1, (index * 0.1) + 0.3). Cap it at ~10 items to avoid the animation lasting too long. For longer lists, use AnimatedList or SliverAnimatedList which handle enter/exit animations natively. See the staggered animations tutorial for the official walkthrough.

Hero Animations — Shared Element Transitions

Hero animations create a seamless visual connection between two screens by animating a shared element across the navigation transition. They're the cornerstone of good mobile navigation UX — used for product catalogs, photo galleries, user profiles, and any detail-screen pattern. The hero animations guide explains the mechanics in detail.

// Source screen: Product grid
class ProductGrid extends StatelessWidget {
 final List<Product> products;
 const ProductGrid({super.key, required this.products});

 @override
 Widget build(BuildContext context) {
 return GridView.builder(
 gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
 crossAxisCount: 2,
 childAspectRatio: 0.75,
 mainAxisSpacing: 12,
 crossAxisSpacing: 12,
 ),
 itemCount: products.length,
 itemBuilder: (context, index) {
 final product = products[index];
 return GestureDetector(
 onTap: () => Navigator.push(
 context,
 PageRouteBuilder(
 transitionDuration: const Duration(milliseconds: 500),
 reverseTransitionDuration: const Duration(milliseconds: 400),
 pageBuilder: (_, __, ___) => ProductDetail(product: product),
 transitionsBuilder: (_, animation, __, child) {
 return FadeTransition(opacity: animation, child: child);
 },
 ),
 ),
 child: Hero(
 tag: 'product-image-${product.id}',
 child: ClipRRect(
 borderRadius: BorderRadius.circular(12),
 child: Image.network(product.imageUrl, fit: BoxFit.cover),
 ),
 ),
 );
 },
 );
 }
}

// Destination screen: Product detail
class ProductDetail extends StatelessWidget {
 final Product product;
 const ProductDetail({super.key, required this.product});

 @override
 Widget build(BuildContext context) {
 return Scaffold(
 body: CustomScrollView(
 slivers: [
 SliverAppBar(
 expandedHeight: 300,
 flexibleSpace: Hero(
 tag: 'product-image-${product.id}',
 child: Image.network(
 product.imageUrl,
 fit: BoxFit.cover,
 width: double.infinity,
 ),
 ),
 ),
 SliverToBoxAdapter(
 child: Padding(
 padding: const EdgeInsets.all(16),
 child: Column(
 crossAxisAlignment: CrossAxisAlignment.start,
 children: [
 Text(product.name,
 style: Theme.of(context).textTheme.headlineSmall),
 const SizedBox(height: 8),
 Text(product.description),
 ],
 ),
 ),
 ),
 ],
 ),
 );
 }
}

The Hero tag must be identical on both screens. Flutter automatically flies the widget from its grid position to the app bar, interpolating size, position, and clipping. Use PageRouteBuilder with a custom FadeTransition for the background to create a polished shared-element experience. For advanced customisation, override flightShuttleBuilder to render a custom widget during the flight.

Custom Page Route Transitions

The default MaterialPageRoute slide-from-right works for most screens, but custom transitions elevate your app's feel significantly. Here are three production transitions we reuse across projects:

// 1. Fade + Scale transition — great for modal-style detail screens
class FadeScaleRoute<T> extends PageRouteBuilder<T> {
 final Widget page;
 FadeScaleRoute({required this.page})
 : super(
 transitionDuration: const Duration(milliseconds: 400),
 reverseTransitionDuration: const Duration(milliseconds: 300),
 pageBuilder: (_, __, ___) => page,
 transitionsBuilder: (_, animation, __, child) {
 return FadeTransition(
 opacity: CurvedAnimation(
 parent: animation,
 curve: Curves.easeOut,
 ),
 child: ScaleTransition(
 scale: Tween<double>(begin: 0.92, end: 1.0).animate(
 CurvedAnimation(parent: animation, curve: Curves.easeOutCubic),
 ),
 child: child,
 ),
 );
 },
 );
}

// 2. Shared axis transition — Material 3 style forward/backward
class SharedAxisRoute<T> extends PageRouteBuilder<T> {
 final Widget page;
 SharedAxisRoute({required this.page})
 : super(
 transitionDuration: const Duration(milliseconds: 350),
 reverseTransitionDuration: const Duration(milliseconds: 300),
 pageBuilder: (_, __, ___) => page,
 transitionsBuilder: (_, animation, secondaryAnimation, child) {
 final slideIn = Tween<Offset>(
 begin: const Offset(0.05, 0.0),
 end: Offset.zero,
 ).animate(CurvedAnimation(
 parent: animation,
 curve: Curves.easeOutCubic,
 ));
 return FadeTransition(
 opacity: animation,
 child: SlideTransition(position: slideIn, child: child),
 );
 },
 );
}

// 3. Slide up transition — for bottom sheet-style full screens
class SlideUpRoute<T> extends PageRouteBuilder<T> {
 final Widget page;
 SlideUpRoute({required this.page})
 : super(
 transitionDuration: const Duration(milliseconds: 400),
 reverseTransitionDuration: const Duration(milliseconds: 350),
 pageBuilder: (_, __, ___) => page,
 transitionsBuilder: (_, animation, __, child) {
 return SlideTransition(
 position: Tween<Offset>(
 begin: const Offset(0.0, 1.0),
 end: Offset.zero,
 ).animate(CurvedAnimation(
 parent: animation,
 curve: Curves.easeOutCubic,
 )),
 child: child,
 );
 },
 );
}

Package these as reusable route classes in a shared/routes/ directory. The FadeScale works beautifully for detail screens, SharedAxis for forward navigation flows (onboarding steps), and SlideUp for full-screen modals. Match the transition to the navigation semantics and your app feels cohesive rather than random.

Physics-Based Animations — Springs, Flings & Gravity

Duration-based animations feel fine for UI transitions, but when users interact with draggable elements, fixed durations feel artificial. A fast fling should result in a faster animation than a slow drag. Physics-based animations solve this by letting the simulation determine the duration. Flutter provides SpringSimulation, FrictionSimulation, and GravitySimulation for different physical behaviors.

// Spring animation: Pull-to-refresh indicator
class PullToRefreshIndicator extends StatefulWidget {
 const PullToRefreshIndicator({super.key});

 @override
 State<PullToRefreshIndicator> createState() => _PullToRefreshIndicatorState();
}

class _PullToRefreshIndicatorState extends State<PullToRefreshIndicator>
 with SingleTickerProviderStateMixin {
 late final AnimationController _springController;
 late Animation<double> _springAnimation;

 @override
 void initState() {
 super.initState();
 _springController = AnimationController(vsync: this);
 _springAnimation = _springController.drive(Tween(begin: 0.0, end: 0.0));
 }

 void _onDragEnd(double dragExtent) {
 // Spring simulation: mass 1, stiffness 300, damping 20
 final spring = SpringDescription(
 mass: 1.0,
 stiffness: 300.0,
 damping: 20.0,
 );
 final simulation = SpringSimulation(spring, dragExtent, 0.0, 0.0);

 _springAnimation = _springController.drive(
 Tween(begin: dragExtent, end: 0.0),
 );
 _springController.animateWith(simulation);
 }

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

 @override
 Widget build(BuildContext context) {
 return AnimatedBuilder(
 animation: _springController,
 builder: (context, child) {
 return Transform.translate(
 offset: Offset(0, _springAnimation.value),
 child: child,
 );
 },
 child: const RefreshIcon(),
 );
 }
}

// Fling animation: Dismissible card with velocity
void _onFlingDismiss(DragEndDetails details, AnimationController controller) {
 final velocity = details.velocity.pixelsPerSecond.dx;
 // FrictionSimulation: natural deceleration
 final simulation = FrictionSimulation(
 0.135, // friction coefficient
 controller.value,
 velocity / 1000, // normalize velocity
 );
 controller.animateWith(simulation);
}

The SpringDescription takes three parameters: mass (heavier = slower), stiffness (higher = snappier), and damping (higher = less oscillation). Tuning these values is an art — we typically start with mass: 1, stiffness: 300, damping: 20 and adjust damping until the oscillation feels right. A damping ratio below ~15 gives a visible bounce; above ~25 gives a smooth settle.

Gesture-Driven Animations — Swipe, Drag & Fling

The most satisfying mobile animations respond directly to the user's finger. The key is connecting gesture position to animation value, then using physics to complete the animation when the user lifts their finger.

// Swipe-to-action card (like Tinder swipe or email dismiss)
class SwipeCard extends StatefulWidget {
 final Widget child;
 final VoidCallback onSwipeRight;
 final VoidCallback onSwipeLeft;
 const SwipeCard({
 super.key,
 required this.child,
 required this.onSwipeRight,
 required this.onSwipeLeft,
 });

 @override
 State<SwipeCard> createState() => _SwipeCardState();
}

class _SwipeCardState extends State<SwipeCard>
 with SingleTickerProviderStateMixin {
 late final AnimationController _controller;
 Offset _dragOffset = Offset.zero;
 bool _isDragging = false;

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

 void _onPanUpdate(DragUpdateDetails details) {
 setState(() {
 _isDragging = true;
 _dragOffset += details.delta;
 });
 }

 void _onPanEnd(DragEndDetails details) {
 _isDragging = false;
 final screenWidth = MediaQuery.sizeOf(context).width;
 final threshold = screenWidth * 0.35;

 if (_dragOffset.dx.abs() > threshold) {
 // Swipe complete — animate off screen
 final targetX = _dragOffset.dx > 0 ? screenWidth * 1.5 : -screenWidth * 1.5;
 final targetOffset = Offset(targetX, _dragOffset.dy);

 _controller.forward().then((_) {
 if (_dragOffset.dx > 0) {
 widget.onSwipeRight();
 } else {
 widget.onSwipeLeft();
 }
 _resetCard();
 });
 } else {
 // Spring back to center
 _resetCard();
 }
 }

 void _resetCard() {
 setState(() {
 _dragOffset = Offset.zero;
 _controller.reset();
 });
 }

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

 @override
 Widget build(BuildContext context) {
 // Rotation proportional to horizontal drag
 final rotation = _dragOffset.dx / 800;
 return GestureDetector(
 onPanUpdate: _onPanUpdate,
 onPanEnd: _onPanEnd,
 child: Transform(
 transform: Matrix4.identity()
 ..translate(_dragOffset.dx, _dragOffset.dy)
 ..rotateZ(rotation),
 alignment: Alignment.center,
 child: widget.child,
 ),
 );
 }
}

The pattern: track gesture position with onPanUpdate, update visual state in real-time, then on onPanEnd either animate to completion (past threshold) or spring back to origin (below threshold). This gives the user direct control during the drag and a smooth finish after release. For a deeper dive into gesture-based UX patterns, see our UI transitions guide.

Animations with CustomPainter

When built-in widgets can't represent the visual you need, CustomPainter combined with AnimationController lets you draw anything and animate it frame by frame. This is how you build circular progress indicators, waveform visualizers, particle effects, and custom charts.

// Animated circular progress with gradient stroke
class AnimatedCircularProgress extends StatefulWidget {
 final double targetProgress; // 0.0 to 1.0
 const AnimatedCircularProgress({super.key, required this.targetProgress});

 @override
 State<AnimatedCircularProgress> createState() =>
 _AnimatedCircularProgressState();
}

class _AnimatedCircularProgressState extends State<AnimatedCircularProgress>
 with SingleTickerProviderStateMixin {
 late final AnimationController _controller;
 late Animation<double> _progressAnimation;

 @override
 void initState() {
 super.initState();
 _controller = AnimationController(
 duration: const Duration(milliseconds: 1200),
 vsync: this,
 );
 _progressAnimation = Tween<double>(
 begin: 0.0,
 end: widget.targetProgress,
 ).animate(CurvedAnimation(
 parent: _controller,
 curve: Curves.easeOutCubic,
 ));
 _controller.forward();
 }

 @override
 void didUpdateWidget(AnimatedCircularProgress oldWidget) {
 super.didUpdateWidget(oldWidget);
 if (oldWidget.targetProgress != widget.targetProgress) {
 _progressAnimation = Tween<double>(
 begin: _progressAnimation.value,
 end: widget.targetProgress,
 ).animate(CurvedAnimation(
 parent: _controller,
 curve: Curves.easeInOutCubic,
 ));
 _controller.forward(from: 0.0);
 }
 }

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

 @override
 Widget build(BuildContext context) {
 return AnimatedBuilder(
 animation: _controller,
 builder: (context, child) {
 return CustomPaint(
 size: const Size(120, 120),
 painter: _CircularProgressPainter(
 progress: _progressAnimation.value,
 strokeWidth: 10,
 backgroundColor: Colors.grey.shade200,
 foregroundGradient: const SweepGradient(
 colors: [Color(0xFF6366F1), Color(0xFF8B5CF6), Color(0xFFEC4899)],
 ),
 ),
 );
 },
 );
 }
}

class _CircularProgressPainter extends CustomPainter {
 final double progress;
 final double strokeWidth;
 final Color backgroundColor;
 final Gradient foregroundGradient;

 _CircularProgressPainter({
 required this.progress,
 required this.strokeWidth,
 required this.backgroundColor,
 required this.foregroundGradient,
 });

 @override
 void paint(Canvas canvas, Size size) {
 final center = Offset(size.width / 2, size.height / 2);
 final radius = (size.width - strokeWidth) / 2;
 final rect = Rect.fromCircle(center: center, radius: radius);

 // Background circle
 final bgPaint = Paint()
 ..color = backgroundColor
 ..style = PaintingStyle.stroke
 ..strokeWidth = strokeWidth
 ..strokeCap = StrokeCap.round;
 canvas.drawCircle(center, radius, bgPaint);

 // Foreground arc with gradient
 final fgPaint = Paint()
 ..shader = foregroundGradient.createShader(rect)
 ..style = PaintingStyle.stroke
 ..strokeWidth = strokeWidth
 ..strokeCap = StrokeCap.round;
 canvas.drawArc(
 rect,
 -math.pi / 2, // Start from top
 2 * math.pi * progress,
 false,
 fgPaint,
 );
 }

 @override
 bool shouldRepaint(_CircularProgressPainter oldDelegate) {
 return oldDelegate.progress != progress;
 }
}

The shouldRepaint method is critical for performance — only return true when the painting data actually changed. Without it, the painter redraws on every frame even when nothing changed. For complex custom paintings, also consider wrapping the CustomPaint in a RepaintBoundary to isolate it from the surrounding widget tree's repaints.

Rive & Lottie — Third-Party Animation Engines

For animations that go beyond what you'd want to code by hand — character animations, complex icon transitions, interactive illustrations — Rive and Lottie are production-proven solutions. We use both, depending on the project requirements.

Lottie (pub.dev/packages/lottie): Renders After Effects animations exported as JSON. Your design team creates the animation in After Effects, exports it with the Bodymovin plugin, and you play it in Flutter with minimal code.

// Lottie: Success checkmark animation after form submission
class SuccessAnimation extends StatelessWidget {
 const SuccessAnimation({super.key});

 @override
 Widget build(BuildContext context) {
 return Lottie.asset(
 'assets/animations/success-check.json',
 width: 200,
 height: 200,
 repeat: false,
 onLoaded: (composition) {
 // Animation auto-plays and stops after one cycle
 },
 );
 }
}

// Lottie with controller for interactive playback
class InteractiveLottie extends StatefulWidget {
 const InteractiveLottie({super.key});

 @override
 State<InteractiveLottie> createState() => _InteractiveLottieState();
}

class _InteractiveLottieState extends State<InteractiveLottie>
 with SingleTickerProviderStateMixin {
 late final AnimationController _lottieController;

 @override
 void initState() {
 super.initState();
 _lottieController = AnimationController(vsync: this);
 }

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

 @override
 Widget build(BuildContext context) {
 return GestureDetector(
 onTap: () {
 if (_lottieController.isCompleted) {
 _lottieController.reverse();
 } else {
 _lottieController.forward();
 }
 },
 child: Lottie.asset(
 'assets/animations/toggle-switch.json',
 controller: _lottieController,
 onLoaded: (composition) {
 _lottieController.duration = composition.duration;
 },
 ),
 );
 }
}

Rive (pub.dev/packages/rive): Purpose-built for interactive runtime animations with state machines. Where Lottie plays linear timelines, Rive animations respond to inputs, switch between states, and blend between animations dynamically.

// Rive: Interactive character that reacts to button states
class RiveButton extends StatefulWidget {
 final VoidCallback onPressed;
 const RiveButton({super.key, required this.onPressed});

 @override
 State<RiveButton> createState() => _RiveButtonState();
}

class _RiveButtonState extends State<RiveButton> {
 StateMachineController? _riveController;
 SMITrigger? _pressInput;
 SMIBool? _hoverInput;

 void _onRiveInit(Artboard artboard) {
 _riveController = StateMachineController.fromArtboard(
 artboard,
 'ButtonStateMachine', // State machine name from Rive editor
 );
 if (_riveController != null) {
 artboard.addController(_riveController!);
 _pressInput = _riveController!.findInput<bool>('press') as SMITrigger?;
 _hoverInput = _riveController!.findInput<bool>('hover') as SMIBool?;
 }
 }

 @override
 Widget build(BuildContext context) {
 return MouseRegion(
 onEnter: (_) => _hoverInput?.value = true,
 onExit: (_) => _hoverInput?.value = false,
 child: GestureDetector(
 onTap: () {
 _pressInput?.fire();
 widget.onPressed();
 },
 child: SizedBox(
 width: 200,
 height: 60,
 child: RiveAnimation.asset(
 'assets/animations/button.riv',
 onInit: _onRiveInit,
 fit: BoxFit.contain,
 ),
 ),
 ),
 );
 }
}

📊 Rive vs Lottie file sizes (our benchmarks)

For a 3-second decorative loading animation: Lottie JSON ~45KB, Rive .riv ~12KB. For a 10-state interactive button: Lottie requires 10 separate JSON files (~400KB total), Rive uses one .riv file with a state machine (~35KB). If your animations are decorative and linear, either works. If they're interactive, Rive wins on both capability and file size. Both render on a separate compositing layer, so they don't affect Flutter widget rebuild performance.

Performance Optimization — Hitting 60fps

Animation performance is where sloppy code becomes visible. A dropped frame during a loading spinner is forgivable. A stutter during a page transition makes the whole app feel cheap. Here are the optimization techniques we enforce in every code review:

1. Use AnimatedBuilder with the child parameter. This is non-negotiable. The child is built once and reused on every animation frame.

// BAD: Rebuilds entire subtree 60x/second
AnimatedBuilder(
 animation: _controller,
 builder: (context, child) {
 return Opacity(
 opacity: _fadeAnimation.value,
 child: const ExpensiveWidget(), // Rebuilt every frame!
 );
 },
)

// GOOD: child is built once, reused every frame
AnimatedBuilder(
 animation: _controller,
 builder: (context, child) {
 return Opacity(
 opacity: _fadeAnimation.value,
 child: child, // Reused, not rebuilt
 );
 },
 child: const ExpensiveWidget(), // Built once
)

2. Prefer compositing-friendly properties. Transform (translate, scale, rotate) and Opacity are handled by the GPU compositor and don't trigger layout or repaint. Animating width, height, padding, or margin triggers expensive relayouts.

3. Use RepaintBoundary to isolate animated regions.

// Isolate a continuously animating widget from the rest of the tree
RepaintBoundary(
 child: AnimatedBuilder(
 animation: _loopingController,
 builder: (context, child) {
 return Transform.rotate(
 angle: _rotationAnimation.value,
 child: child,
 );
 },
 child: const LoadingSpinner(),
 ),
)

4. Profile with Flutter DevTools. Enable the Performance overlay (showPerformanceOverlay: true in MaterialApp) and watch for red bars. Use the Timeline view to identify which frames take longer than 16ms. For comprehensive profiling techniques, see the official Flutter performance optimization guide.

5. Dispose controllers. Every AnimationController must be disposed in dispose(). Leaked controllers continue ticking after the widget is removed from the tree, wasting CPU and causing "setState called on disposed widget" errors.

6. Don't animate in build(). Create animations in initState(), not build(). If you create a Tween(...).animate() in build(), you're creating a new Animation object on every frame, which defeats the caching that AnimatedBuilder relies on.

Animation Anti-Patterns to Avoid

These are the most common animation mistakes we see in Flutter code reviews. Each one causes either visual glitches, performance problems, or memory leaks.

// Respecting accessibility: reduce or disable animations
class AccessibleAnimation extends StatefulWidget {
 const AccessibleAnimation({super.key});

 @override
 State<AccessibleAnimation> createState() => _AccessibleAnimationState();
}

class _AccessibleAnimationState extends State<AccessibleAnimation>
 with SingleTickerProviderStateMixin {
 late final AnimationController _controller;

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

 @override
 void didChangeDependencies() {
 super.didChangeDependencies();
 // Check if user prefers reduced motion
 final reduceMotion = MediaQuery.disableAnimationsOf(context);
 if (reduceMotion) {
 _controller.duration = Duration.zero; // Instant, no animation
 } else {
 _controller.duration = const Duration(milliseconds: 600);
 }
 _controller.forward();
 }

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

 @override
 Widget build(BuildContext context) {
 return FadeTransition(
 opacity: _controller,
 child: const ContentWidget(),
 );
 }
}

Production Animation Checklist

Before shipping any screen with animations, run through this checklist. We use this as a PR gate at Flutter Studio — animations that don't pass all items get sent back for revision.

📚 Related Articles

🚀 Need animation expertise for your app?

We've built micro-interactions, complex onboarding flows, and full game-like animations for 50+ client apps. If your project needs motion design and implementation, let's discuss your project. Check our Flutter development services.

Frequently Asked Questions

What is the best way to animate widgets in Flutter?

For simple property changes (opacity, size, color, padding), use implicit animations like AnimatedContainer, AnimatedOpacity, or AnimatedSwitcher — they handle the AnimationController lifecycle automatically. For complex, multi-step, or interactive animations, use explicit animations with AnimationController, Tween, and AnimatedBuilder. For physics-based motion like flings and springs, use SpringSimulation with AnimationController. The rule of thumb: start with implicit animations and only move to explicit when you need control over timing, sequencing, or user-driven interaction.

How does AnimationController work in Flutter?

AnimationController is the core engine of Flutter's explicit animation system. It generates values from 0.0 to 1.0 (by default) over a specified duration, ticking on every frame via the TickerProvider (SingleTickerProviderStateMixin or TickerProviderStateMixin). You pair it with Tween objects to map that 0.0–1.0 range to any value range (colors, sizes, offsets), and with CurvedAnimation to apply easing curves. The controller provides methods like forward(), reverse(), repeat(), and animateTo() for playback control. Always dispose the controller in dispose() to prevent memory leaks.

When should I use implicit vs explicit animations in Flutter?

Use implicit animations (AnimatedContainer, AnimatedOpacity, AnimatedPositioned, etc.) when you want a widget property to animate automatically whenever its value changes — no manual controller needed. Use explicit animations when you need: precise timing control, animation sequences or staggering, user-gesture-driven animations, looping or repeating, or animations that depend on other animations. Implicit animations are simpler but less flexible. Explicit animations require more code but give you full control over every frame.

How do I create staggered animations in Flutter?

Staggered animations use a single AnimationController with multiple Tween/Interval pairs, each covering a different time slice of the controller's duration. Define each animation with an Interval curve that specifies when it starts and ends within the 0.0–1.0 range. For example: fade-in from 0.0–0.3, slide-up from 0.1–0.5, scale from 0.3–0.7. All animations share one controller, so they stay perfectly synchronized. Use AnimatedBuilder to rebuild efficiently when the controller ticks.

How do I optimize Flutter animation performance?

Key optimizations: 1) Use RepaintBoundary to isolate animated widgets from the render tree. 2) Prefer Transform and Opacity over animating layout properties (width, height, padding) which trigger expensive relayouts. 3) Use AnimatedBuilder with the child parameter — it only rebuilds its builder subtree, not the static child. 4) Enable const constructors for child widgets inside AnimatedBuilder. 5) Profile with Flutter DevTools Performance overlay to detect jank. 6) For complex vector animations, use Rive or Lottie which render on a separate compositing layer. See our performance guide for the full playbook. Also see the official Flutter performance best practices.

Should I use Rive or Lottie for Flutter animations?

Both are excellent. Lottie is better when your design team already uses After Effects — they export animations as JSON files that Flutter renders natively. Rive is better for interactive, state-machine-driven animations that respond to user input. Rive is purpose-built for runtime interactivity and produces smaller file sizes (~12KB for a loading animation vs ~45KB for Lottie). For simple decorative animations (loading spinners, success checkmarks), either works. For interactive animations (onboarding flows, game-like elements), Rive is the stronger choice.

How do Hero animations work in Flutter?

Hero animations create shared-element transitions between routes. Wrap the same widget on both screens with a Hero widget using matching tag values. When navigating, Flutter automatically interpolates the widget from its position on the source screen to its position on the destination screen, animating size, position, and shape. You can customize the flight with flightShuttleBuilder and the curve with the PageRoute's transition duration. Hero animations work with Navigator.push and go_router transitions.

What are physics-based animations in Flutter and when should I use them?

Physics-based animations use real-world physics simulations (springs, friction, gravity) instead of fixed durations and curves. Flutter provides SpringSimulation, FrictionSimulation, GravitySimulation, and BouncingScrollSimulation. Use them when animations should feel natural and respond to velocity — for example: fling-to-dismiss gestures, pull-to-refresh bounce effects, draggable cards that snap to positions, or bottom sheets that spring open. The key advantage: the animation duration is determined by the physics, not a hardcoded value, so the same animation feels natural whether the user flicks fast or slow.

Share this article:

Have an App Idea?

Let our team turn your vision into reality with Flutter.