
Introduction
Animations make Flutter apps feel smooth, responsive, and delightful to use. When implemented well, they help users understand what’s happening on screen, provide feedback for interactions, and guide attention naturally. While Flutter provides many ready-made animated widgets like AnimatedContainer and Hero, custom animations using AnimationController and Tween give you precise control over timing, curves, sequences, and complex coordinated motion.
In this comprehensive guide, you’ll learn how these animation primitives work together, how to build performant custom animations from scratch, and advanced patterns for staggered animations, gesture-driven interactions, and physics-based motion.
Why Custom Animations Matter in Flutter
Animations aren’t just about visual appeal—they’re a fundamental part of user experience that communicates state changes, guides navigation, and provides feedback.
- Clarify transitions: Show users where content comes from and goes to.
- Improve perceived performance: Smooth loading states feel faster than sudden changes.
- Guide attention: Draw focus to important elements naturally.
- Provide feedback: Confirm that interactions were registered.
- Express brand identity: Custom motion can differentiate your app.
Flutter Animation Fundamentals
Understanding the core concepts is essential for building effective custom animations.
// The animation system hierarchy
//
// AnimationController - Produces values from 0.0 to 1.0 over duration
// ↓
// CurvedAnimation - Applies easing curves to the raw values
// ↓
// Tween - Maps 0.0-1.0 to any range (colors, offsets, sizes, etc.)
// ↓
// Animation<T> - The final animated value used by widgets
AnimationController Deep Dive
The AnimationController is the engine that drives all explicit animations. It produces values over time and provides control methods.
import 'package:flutter/material.dart';
class AnimatedLogo extends StatefulWidget {
const AnimatedLogo({super.key});
@override
State<AnimatedLogo> createState() => _AnimatedLogoState();
}
class _AnimatedLogoState extends State<AnimatedLogo>
with SingleTickerProviderStateMixin {
// Controller manages animation timing and state
late AnimationController _controller;
// Animation holds the interpolated value
late Animation<double> _animation;
@override
void initState() {
super.initState();
// Create controller with vsync for efficiency
// vsync prevents animation work when widget is off-screen
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
// Create tween and connect to controller
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(_controller);
// Start the animation
_controller.forward();
}
@override
void dispose() {
// CRITICAL: Always dispose controllers to prevent memory leaks
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Opacity(
opacity: _animation.value,
child: child,
);
},
// child is not rebuilt during animation - performance optimization
child: const FlutterLogo(size: 100),
);
}
}
Controller Methods and Properties
class AnimationControllerDemo extends StatefulWidget {
const AnimationControllerDemo({super.key});
@override
State<AnimationControllerDemo> createState() => _AnimationControllerDemoState();
}
class _AnimationControllerDemoState extends State<AnimationControllerDemo>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
// Optional: Set bounds other than 0.0-1.0
lowerBound: 0.0,
upperBound: 1.0,
);
// Listen to animation value changes
_controller.addListener(() {
print('Value: ${_controller.value}');
});
// Listen to animation status changes
_controller.addStatusListener((status) {
switch (status) {
case AnimationStatus.dismissed:
print('Animation at start');
break;
case AnimationStatus.forward:
print('Animation running forward');
break;
case AnimationStatus.reverse:
print('Animation running reverse');
break;
case AnimationStatus.completed:
print('Animation completed');
break;
}
});
}
void _playAnimations() {
// Play forward from current position
_controller.forward();
// Play forward from beginning
_controller.forward(from: 0.0);
// Play reverse from current position
_controller.reverse();
// Play reverse from end
_controller.reverse(from: 1.0);
// Reset to beginning without animation
_controller.reset();
// Jump to specific value without animation
_controller.value = 0.5;
// Animate to specific value
_controller.animateTo(0.75);
// Repeat animation
_controller.repeat();
// Repeat with reverse
_controller.repeat(reverse: true);
// Stop animation
_controller.stop();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
Tweens: Value Interpolation
A Tween defines how values change from a start point to an end point. It maps the controller’s 0.0-1.0 range to any value type.
class TweenExamples extends StatefulWidget {
const TweenExamples({super.key});
@override
State<TweenExamples> createState() => _TweenExamplesState();
}
class _TweenExamplesState extends State<TweenExamples>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
// Different tween types
late Animation<double> _opacityAnimation;
late Animation<double> _sizeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<Color?> _colorAnimation;
late Animation<double> _rotationAnimation;
late Animation<BorderRadius?> _borderRadiusAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
// Double tween for opacity
_opacityAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(_controller);
// Double tween for size
_sizeAnimation = Tween<double>(
begin: 50.0,
end: 150.0,
).animate(_controller);
// Offset tween for slide transitions
_slideAnimation = Tween<Offset>(
begin: const Offset(0.0, 1.0), // Start below
end: Offset.zero, // End at normal position
).animate(_controller);
// Color tween
_colorAnimation = ColorTween(
begin: Colors.blue,
end: Colors.red,
).animate(_controller);
// Rotation in radians
_rotationAnimation = Tween<double>(
begin: 0.0,
end: 2 * 3.14159, // Full rotation
).animate(_controller);
// Border radius tween
_borderRadiusAnimation = BorderRadiusTween(
begin: BorderRadius.circular(0),
end: BorderRadius.circular(50),
).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Container(
width: _sizeAnimation.value,
height: _sizeAnimation.value,
decoration: BoxDecoration(
color: _colorAnimation.value,
borderRadius: _borderRadiusAnimation.value,
),
child: Transform.rotate(
angle: _rotationAnimation.value,
child: Opacity(
opacity: _opacityAnimation.value,
child: const Icon(Icons.star, color: Colors.white),
),
),
);
},
);
}
}
Curves for Natural Motion
Linear animations feel mechanical. Curves add easing that makes motion feel natural and polished.
class CurvedAnimationExample extends StatefulWidget {
const CurvedAnimationExample({super.key});
@override
State<CurvedAnimationExample> createState() => _CurvedAnimationExampleState();
}
class _CurvedAnimationExampleState extends State<CurvedAnimationExample>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
// Apply curve to animation
final curvedAnimation = CurvedAnimation(
parent: _controller,
// Different curves for forward and reverse
curve: Curves.easeOutCubic,
reverseCurve: Curves.easeInCubic,
);
_animation = Tween<double>(
begin: 0.0,
end: 200.0,
).animate(curvedAnimation);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
// Common curves and when to use them:
//
// Curves.linear - Constant speed, rarely used
// Curves.easeIn - Slow start, fast end - exiting elements
// Curves.easeOut - Fast start, slow end - entering elements
// Curves.easeInOut - Slow start and end - general purpose
// Curves.easeOutCubic - Smooth deceleration - recommended default
// Curves.easeOutBack - Overshoots slightly - playful feel
// Curves.bounceOut - Bouncy ending - fun, attention-grabbing
// Curves.elasticOut - Spring effect - dynamic, energetic
// Curves.fastOutSlowIn - Material Design standard curve
AnimatedBuilder vs AnimatedWidget
// Option 1: AnimatedBuilder - inline, flexible
class UsingAnimatedBuilder extends StatefulWidget {
const UsingAnimatedBuilder({super.key});
@override
State<UsingAnimatedBuilder> createState() => _UsingAnimatedBuilderState();
}
class _UsingAnimatedBuilderState extends State<UsingAnimatedBuilder>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_animation = Tween<double>(begin: 0.0, end: 1.0).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
// child is cached and not rebuilt during animation
child: const Text('Hello'),
builder: (context, child) {
return Transform.scale(
scale: _animation.value,
child: child, // Reuse cached child
);
},
);
}
}
// Option 2: AnimatedWidget - reusable, cleaner
class ScaleAnimatedWidget extends AnimatedWidget {
final Widget child;
const ScaleAnimatedWidget({
super.key,
required Animation<double> animation,
required this.child,
}) : super(listenable: animation);
Animation<double> get _animation => listenable as Animation<double>;
@override
Widget build(BuildContext context) {
return Transform.scale(
scale: _animation.value,
child: child,
);
}
}
// Usage
class UsingAnimatedWidget extends StatefulWidget {
const UsingAnimatedWidget({super.key});
@override
State<UsingAnimatedWidget> createState() => _UsingAnimatedWidgetState();
}
class _UsingAnimatedWidgetState extends State<UsingAnimatedWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 500),
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ScaleAnimatedWidget(
animation: _animation,
child: const FlutterLogo(size: 100),
);
}
}
Staggered Animations
Staggered animations create sequences where different elements animate at different times, creating a flowing effect.
class StaggeredAnimation extends StatefulWidget {
const StaggeredAnimation({super.key});
@override
State<StaggeredAnimation> createState() => _StaggeredAnimationState();
}
class _StaggeredAnimationState extends State<StaggeredAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
// Staggered intervals
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
);
// First: Fade in (0% - 30% of total duration)
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(
0.0,
0.3,
curve: Curves.easeOut,
),
),
);
// Second: Slide up (20% - 60% of total duration)
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.5),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(
0.2,
0.6,
curve: Curves.easeOutCubic,
),
),
);
// Third: Scale up (50% - 100% of total duration)
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(
0.5,
1.0,
curve: Curves.easeOutBack,
),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: child,
),
),
);
},
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(16),
),
child: const Center(
child: Text(
'Staggered',
style: TextStyle(color: Colors.white, fontSize: 24),
),
),
),
);
}
}
// Staggered list animation
class StaggeredListAnimation extends StatefulWidget {
final List<String> items;
const StaggeredListAnimation({super.key, required this.items});
@override
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<Animation<double>> _itemAnimations;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(milliseconds: 200 * widget.items.length + 500),
);
// Create staggered animations for each item
_itemAnimations = List.generate(widget.items.length, (index) {
final start = index * 0.1; // 10% delay between each item
final end = start + 0.4; // Each animation lasts 40% of total
return Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(
start.clamp(0.0, 1.0),
end.clamp(0.0, 1.0),
curve: Curves.easeOutCubic,
),
),
);
});
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: widget.items.length,
itemBuilder: (context, index) {
return AnimatedBuilder(
animation: _itemAnimations[index],
builder: (context, child) {
return Transform.translate(
offset: Offset(0, 50 * (1 - _itemAnimations[index].value)),
child: Opacity(
opacity: _itemAnimations[index].value,
child: child,
),
);
},
child: ListTile(
title: Text(widget.items[index]),
),
);
},
);
}
}
Gesture-Driven Animations
class DraggableCard extends StatefulWidget {
const DraggableCard({super.key});
@override
State<DraggableCard> createState() => _DraggableCardState();
}
class _DraggableCardState extends State<DraggableCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
Offset _dragOffset = Offset.zero;
Offset _startOffset = Offset.zero;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _onPanStart(DragStartDetails details) {
_controller.stop();
_startOffset = _dragOffset;
}
void _onPanUpdate(DragUpdateDetails details) {
setState(() {
_dragOffset += details.delta;
});
}
void _onPanEnd(DragEndDetails details) {
// Animate back to center
final animation = Tween<Offset>(
begin: _dragOffset,
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeOutCubic,
),
);
animation.addListener(() {
setState(() {
_dragOffset = animation.value;
});
});
_controller.forward(from: 0.0);
}
@override
Widget build(BuildContext context) {
final rotation = _dragOffset.dx / 500; // Rotate based on horizontal drag
return GestureDetector(
onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate,
onPanEnd: _onPanEnd,
child: Transform(
transform: Matrix4.identity()
..translate(_dragOffset.dx, _dragOffset.dy)
..rotateZ(rotation),
alignment: Alignment.center,
child: Container(
width: 300,
height: 400,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: const Center(
child: Text('Drag me!'),
),
),
),
);
}
}
Multiple Animation Controllers
class MultipleAnimations extends StatefulWidget {
const MultipleAnimations({super.key});
@override
State<MultipleAnimations> createState() => _MultipleAnimationsState();
}
class _MultipleAnimationsState extends State<MultipleAnimations>
with TickerProviderStateMixin { // Note: TickerProviderStateMixin for multiple
late AnimationController _rotationController;
late AnimationController _scaleController;
late AnimationController _colorController;
@override
void initState() {
super.initState();
// Independent animation controllers
_rotationController = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(); // Continuous rotation
_scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
)..repeat(reverse: true); // Pulsing scale
_colorController = AnimationController(
vsync: this,
duration: const Duration(seconds: 3),
)..repeat(reverse: true); // Color cycling
}
@override
void dispose() {
_rotationController.dispose();
_scaleController.dispose();
_colorController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
// Combine multiple animations with Listenable.merge
animation: Listenable.merge([
_rotationController,
_scaleController,
_colorController,
]),
builder: (context, child) {
return Transform.rotate(
angle: _rotationController.value * 2 * 3.14159,
child: Transform.scale(
scale: 0.8 + (_scaleController.value * 0.4),
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: ColorTween(
begin: Colors.blue,
end: Colors.purple,
).evaluate(_colorController),
borderRadius: BorderRadius.circular(16),
),
),
),
);
},
);
}
}
Common Mistakes to Avoid
1. Not Disposing Controllers
// WRONG - Memory leak
class BadAnimation extends StatefulWidget {
// ...
}
class _BadAnimationState extends State<BadAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
}
// Missing dispose()!
}
// CORRECT - Always dispose
class GoodAnimation extends StatefulWidget {
const GoodAnimation({super.key});
@override
State<GoodAnimation> createState() => _GoodAnimationState();
}
class _GoodAnimationState extends State<GoodAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => const Placeholder();
}
2. Rebuilding the Entire Widget Tree
// WRONG - Rebuilds everything including static child
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, _) {
return Opacity(
opacity: _animation.value,
child: ExpensiveWidget(), // Rebuilt every frame!
);
},
);
}
// CORRECT - Child is cached
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
child: const ExpensiveWidget(), // Built once, cached
builder: (context, child) {
return Opacity(
opacity: _animation.value,
child: child, // Reused
);
},
);
}
3. Using setState Instead of AnimatedBuilder
// WRONG - Causes unnecessary rebuilds
_controller.addListener(() {
setState(() {}); // Rebuilds entire widget
});
// CORRECT - Use AnimatedBuilder for targeted rebuilds
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
// Only this subtree rebuilds
return Transform.scale(
scale: _animation.value,
child: child,
);
},
child: const StaticContent(),
)
Best Practices Summary
- Always dispose controllers: Prevent memory leaks in dispose() method.
- Use vsync: Prevents animation work when widget is off-screen.
- Cache static children: Pass child parameter to AnimatedBuilder.
- Apply curves: Easing makes motion feel natural and polished.
- Keep animations short: 200-500ms for UI transitions, 800-1200ms for emphasis.
- Respect accessibility: Check MediaQuery.disableAnimations.
- Test on real devices: Animations may perform differently than simulators.
Conclusion
Custom animations with AnimationController and Tween give Flutter developers precise control over motion. By combining controllers, tweens, curves, and intervals, you can build smooth transitions, staggered sequences, and gesture-driven interactions that improve clarity and usability. Understanding when to use AnimatedBuilder versus implicit animations helps you choose the right tool for each situation.
If you want to create scalable UI systems, read Building Reusable UI Components in Flutter: Best Practices. For performance-focused guidance, see Flutter Performance Optimization Tips: Build Faster, Run Smoother. For official documentation, explore the Flutter animation documentation and the AnimationController API reference.