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):
- Ticker — Fires a callback on every frame (~60 times/sec or 120
on ProMotion
displays). It's the heartbeat. When a widget mixes in
SingleTickerProviderStateMixin, it gets aTickerthat's tied to the widget's lifecycle and pauses when the widget is off-screen. - AnimationController — Driven by a Ticker.
Produces a linear value from
lowerBound(default 0.0) toupperBound(default 1.0) over aduration. Think of it as a progress bar that ticks forward every frame. - Tween — Maps the controller's 0.0–1.0 range to any value
type. A
ColorTweenmaps toColor. ATween<double>maps to pixel values. AnAlignmentTweenmaps to positions. - Curve — Transforms the linear progress into non-linear timing.
An
easeInOutcurve starts slow, speeds up, then slows down — making the same linear controller output feel natural.
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:
AnimatedContainer— Size, color, padding, margin, decoration, alignment, transformAnimatedOpacity— Opacity (fade in/out)AnimatedPositioned— Position within aStackAnimatedPadding— Padding changesAnimatedSwitcher— Cross-fade between two different widgetsAnimatedCrossFade— Cross-fade between exactly two child widgetsAnimatedAlign— Alignment within parentAnimatedDefaultTextStyle— Text style transitionsAnimatedPhysicalModel— Elevation and shadow changes
// 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:
Tween<double>— Numbers (size, opacity, rotation)ColorTween— Color interpolation with proper color space blendingAlignmentTween— Widget alignment within parentDecorationTween— BoxDecoration (gradient transitions, border changes)RectTween— Rectangles (used in Hero flight paths)BorderRadiusTween— Smooth corner radius transitions
Curve guidelines from our design system:
- Elements entering:
Curves.easeOutorCurves.easeOutCubic— fast start, slow finish - Elements exiting:
Curves.easeInorCurves.easeInCubic— slow start, fast finish - Elements moving:
Curves.easeInOutCubic— smooth both ends - Bounce effects:
Curves.elasticOut— overshoot and settle - Attention-grab:
Curves.bounceOut— bouncing ball at end
// 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.
- Using
setStatein animation listeners instead ofAnimatedBuilder. CallingsetStateon every tick rebuilds the entire widget, not just the animated portion.AnimatedBuilderscopes the rebuild to its own subtree. - Creating multiple
AnimationControllers when one withIntervals would work. Each controller has its own Ticker, ticking independently. For staggered animations on the same screen, use one controller withIntervalcurves. - Missing
vsync: this— usingTickerProviderStateMixinwhen you only have one controller. UseSingleTickerProviderStateMixinfor one controller (more efficient). Only useTickerProviderStateMixinwhen you genuinely have multiple controllers that need independent tickers (rare). - Not disposing controllers in
dispose(). Causes memory leaks and framework errors. Useflutter_lintsor custom lint rules to catch this. - Hardcoding animation durations without considering accessibility. Some users
have "Reduce motion" enabled (accessible via
MediaQuery.disableAnimationsOf(context)). Respect this preference by reducing or skipping animations. The Flutter accessibility guide explains best practices. - Animating layout properties when transform properties would work.
Moving a widget by animating its
leftposition in aStacktriggers relayout. UsingTransform.translateonly triggers repaint — 5-10x cheaper.
// 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.
- ☑ All
AnimationControllers are disposed indispose() - ☑
AnimatedBuilderuses thechildparameter for static subtrees - ☑ Animations use
Transform/Opacityinstead of layout properties where possible - ☑
RepaintBoundaryisolates continuously-animating widgets - ☑ Staggered animations use one controller with
Intervalcurves, not multiple controllers - ☑
Herotags are unique across the entire route stack - ☑ Animation durations respect
MediaQuery.disableAnimationsOf() - ☑ Lottie/Rive assets are cached with
AssetLottieor preloaded ininitState - ☑
shouldRepaintinCustomPaintercompares only changed properties - ☑ Profiled on a mid-range device — no frames exceed 16ms in Performance overlay
- ☑
SingleTickerProviderStateMixinused for single controller;TickerProviderStateMixinonly for multiple - ☑ Animation curves match the UX intent:
easeOutfor enter,easeInfor exit,easeInOutfor move
📚 Related Articles
- Flutter Animations: How to Build Stunning UI Transitions for Better UX
- Flutter Performance Optimization: A Complete Guide
- Beautiful Forms in Flutter: A Complete Guide
- BLoC vs Riverpod in 2026: The Definitive Comparison
- Flutter Clean Architecture: A Practical Guide
- Top 10 Flutter Packages Every Developer Needs in 2026
- Flutter for Web: Building Production-Grade Web Apps
- Flutter App Security: A Complete Guide
🚀 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.