Every high-rated app on the App Store and Google Play shares one invisible quality: deliberate motion. Not flashy effects or gratuitous bouncing β but micro-interactions that make taps feel acknowledged, transitions feel spatial, and loading feel fast. After shipping 50+ Flutter apps at Flutter Studio, we have distilled the animation patterns that actually matter in production into 12 reusable recipes. Each pattern below includes the full implementation, explains why it improves UX, and links to the official Flutter APIs you will use. If you want the deeper animation framework theory, start with our Flutter Animations Masterclass β this guide is the practical cookbook that comes after.
π What You Will Build
This guide walks through 12 production-tested Flutter animation patterns with copy-paste code: shimmer loading, staggered list animations, animated tab indicators, bottom sheet springs, page transitions, hero detail flows, skeleton-to-content, toast notifications, search bar expand/collapse, pull-to-refresh, onboarding pages, and floating action button reveals. Each pattern targets 60Β fps on mid-range devices.
π Prerequisites
This guide assumes familiarity with AnimationController, Tween, and CurvedAnimation. If those are new, read our Animations Masterclass first. You should also be comfortable with Flutter's animation overview concepts.
1. Shimmer Loading Skeletons
Shimmer skeletons replace empty white space during data fetches with animated placeholder shapes that mimic your content layout. They reduce perceived loading time by up to 40% compared to a simple spinner, according to Google's MaterialΒ 3 motion guidelines. The shimmer package handles the gradient animation, but building one from scratch gives you full control over the look and performance.
// Shimmer loading skeleton β built from scratch, no package needed
class ShimmerSkeleton extends StatefulWidget {
const ShimmerSkeleton({super.key});
@override
State<ShimmerSkeleton> createState() => _ShimmerSkeletonState();
}
class _ShimmerSkeletonState extends State<ShimmerSkeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(); // Loop continuously
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) {
return LinearGradient(
colors: const [
Color(0xFFE0E0E0),
Color(0xFFF5F5F5),
Color(0xFFE0E0E0),
],
stops: const [0.0, 0.5, 1.0],
begin: Alignment(-1.0 + 2.0 * _controller.value, 0.0),
end: Alignment(1.0 + 2.0 * _controller.value, 0.0),
).createShader(bounds);
},
child: child!,
);
},
child: const _SkeletonLayout(),
);
}
}
class _SkeletonLayout extends StatelessWidget {
const _SkeletonLayout();
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 48, height: 48,
decoration: BoxDecoration(
color: Colors.grey[300],
shape: BoxShape.circle,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(width: 120, height: 14, color: Colors.grey[300]),
const SizedBox(height: 8),
Container(width: 80, height: 10, color: Colors.grey[300]),
],
),
],
),
const SizedBox(height: 16),
Container(width: double.infinity, height: 12, color: Colors.grey[300]),
const SizedBox(height: 8),
Container(width: double.infinity, height: 12, color: Colors.grey[300]),
const SizedBox(height: 8),
Container(width: 200, height: 12, color: Colors.grey[300]),
],
);
}
}The ShaderMask applies a sliding
LinearGradient over the grey placeholder shapes.
The gradient's begin and end shift with the controller value, creating
the characteristic shimmer sweep. The child parameter in
AnimatedBuilder ensures the skeleton layout is built once
and reused every frame β critical when you have 6β10 shimmer cards on screen at once.
β‘ Performance Tip
Wrap each shimmer card in a RepaintBoundary when displaying multiple
skeletons in a list. Without it, one shimmer card's repaint triggers repainting of all visible
items. On a mid-range device loading 8 cards, this single change reduces frame times from
~22Β ms to ~8Β ms.
2. Staggered List Enter Animations
When a list first loads, having all items appear simultaneously looks flat. Staggering the entrance β each item sliding in 50β80Β ms after the previous β creates a cascade effect that makes the content feel dynamic and fast. This technique is used by nearly every top-tier app: Instagram's feed, Twitter's timeline, and Spotify's playlists all use staggered enter animations. See the official staggered animations cookbook for more examples.
// Staggered list enter β single controller, Interval per item
class StaggeredListView extends StatefulWidget {
final List<Widget> children;
const StaggeredListView({super.key, required this.children});
@override
State<StaggeredListView> createState() => _StaggeredListViewState();
}
class _StaggeredListViewState extends State<StaggeredListView>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: Duration(
milliseconds: 300 + (widget.children.length * 80),
),
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final itemCount = widget.children.length;
return ListView.builder(
itemCount: itemCount,
itemBuilder: (context, index) {
final start = (index * 0.08).clamp(0.0, 0.7);
final end = (start + 0.3).clamp(0.0, 1.0);
final slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOutCubic),
));
final fadeAnimation = Tween<double>(
begin: 0.0, end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOut),
));
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: fadeAnimation,
child: widget.children[index],
),
);
},
);
}
}The pattern uses one AnimationController with
Interval curves calculated per index. Each item's
SlideTransition and
FadeTransition activate at slightly different times,
creating the cascade. Cap visible items at ~10 to keep the total animation under 1.5 seconds
β after that, the stagger effect becomes distracting rather than delightful.
For lists where items are dynamically added or removed after the initial load, use
AnimatedList instead. It provides built-in
insertItem and removeItem methods that animate individual entries
without affecting the rest of the list. Pair it with
SliverAnimatedList when using
CustomScrollView.
3. Animated Tab Indicator
The default TabBar indicator works fine, but a custom animated
indicator that morphs shape during swipes creates a more polished experience. This pattern
uses TabController's animation property to drive a custom
indicator that stretches while transitioning and snaps when settled.
// Custom stretchy tab indicator
class StretchyTabIndicator extends Decoration {
final TabController controller;
final Color color;
final double indicatorHeight;
const StretchyTabIndicator({
required this.controller,
this.color = const Color(0xFF6C63FF),
this.indicatorHeight = 4.0,
});
@override
BoxPainter createBoxPainter([VoidCallback? onChanged]) {
return _StretchyPainter(
controller: controller,
color: color,
indicatorHeight: indicatorHeight,
);
}
}
class _StretchyPainter extends BoxPainter {
final TabController controller;
final Color color;
final double indicatorHeight;
_StretchyPainter({
required this.controller,
required this.color,
required this.indicatorHeight,
});
@override
void paint(Canvas canvas, Offset offset, ImageConfiguration config) {
final tabWidth = config.size!.width;
final animValue = controller.animation!.value;
final t = animValue - animValue.floor();
// Stretch factor peaks at 1.3x mid-transition
final stretchFactor = 1.0 + (0.3 * (1.0 - (2.0 * t - 1.0).abs()));
final left = offset.dx + (animValue * tabWidth) -
((stretchFactor - 1.0) * tabWidth * 0.5);
final width = tabWidth * stretchFactor;
final top = offset.dy + config.size!.height - indicatorHeight;
final rrect = RRect.fromRectAndRadius(
Rect.fromLTWH(left, top, width, indicatorHeight),
Radius.circular(indicatorHeight / 2),
);
canvas.drawRRect(rrect, Paint()..color = color);
}
}
// Usage
TabBar(
controller: _tabController,
indicator: StretchyTabIndicator(controller: _tabController),
tabs: const [
Tab(text: 'Overview'),
Tab(text: 'Reviews'),
Tab(text: 'Photos'),
],
)The indicator width multiplied by the stretchFactor (which peaks at 1.3x mid-transition
and returns to 1.0x at rest) produces a fluid morphing effect. The
controller.animation!.value provides a continuous 0.0βN value as the user swipes
between tabs, so the indicator position updates on every frame without any additional
AnimationController. For a dot-style indicator, replace the RRect draw
call with drawCircle and adjust the width calculation. For more on custom painting
techniques, see our
Animations Masterclass section on CustomPainter.
4. Bottom Sheet with Spring Physics
Standard bottom sheets with fixed animation durations feel disconnected from the user's gesture.
A physics-based bottom sheet that responds to fling velocity and snaps to predefined stop points
creates a native-feeling interaction. Flutter's
DraggableScrollableSheet handles the basic gesture detection,
but adding SpringSimulation physics makes the snap feel natural.
// Spring-physics bottom sheet with snap points
class SpringBottomSheet extends StatefulWidget {
final Widget child;
final List<double> snapPoints;
const SpringBottomSheet({
super.key,
required this.child,
this.snapPoints = const [0.0, 0.5, 1.0],
});
@override
State<SpringBottomSheet> createState() => _SpringBottomSheetState();
}
class _SpringBottomSheetState extends State<SpringBottomSheet>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
double _currentExtent = 0.0;
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this);
_controller.addListener(() {
setState(() => _currentExtent = _controller.value);
});
}
void _onDragUpdate(DragUpdateDetails details, BoxConstraints constraints) {
final delta = -details.primaryDelta! / constraints.maxHeight;
setState(() {
_currentExtent = (_currentExtent + delta).clamp(0.0, 1.0);
});
}
void _onDragEnd(DragEndDetails details, BoxConstraints constraints) {
final velocity = -details.primaryVelocity! / constraints.maxHeight;
double targetSnap = _findNearestSnap(velocity);
final spring = SpringDescription(
mass: 1.0,
stiffness: 500.0,
damping: 30.0,
);
final simulation = SpringSimulation(
spring, _currentExtent, targetSnap, velocity,
);
_controller.value = _currentExtent;
_controller.animateWith(simulation);
}
double _findNearestSnap(double velocity) {
if (velocity.abs() > 2.0) {
if (velocity > 0) {
return widget.snapPoints
.where((s) => s > _currentExtent + 0.05)
.firstOrNull ?? widget.snapPoints.last;
} else {
return widget.snapPoints.reversed
.where((s) => s < _currentExtent - 0.05)
.firstOrNull ?? widget.snapPoints.first;
}
}
return widget.snapPoints.reduce((a, b) =>
(a - _currentExtent).abs() < (b - _currentExtent).abs() ? a : b);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final sheetHeight = constraints.maxHeight * _currentExtent;
return Stack(
children: [
if (_currentExtent > 0.01)
GestureDetector(
onTap: () => _snapTo(0.0),
child: Container(
color: Colors.black.withOpacity(_currentExtent * 0.5),
),
),
Positioned(
left: 0, right: 0, bottom: 0,
height: sheetHeight,
child: GestureDetector(
onVerticalDragUpdate: (d) => _onDragUpdate(d, constraints),
onVerticalDragEnd: (d) => _onDragEnd(d, constraints),
child: Material(
elevation: 8,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(16),
),
child: widget.child,
),
),
),
],
);
},
);
}
void _snapTo(double target) {
final spring = SpringDescription(mass: 1, stiffness: 500, damping: 30);
final simulation = SpringSimulation(spring, _currentExtent, target, 0);
_controller.value = _currentExtent;
_controller.animateWith(simulation);
}
}The SpringDescription with stiffness 500 and damping 30 produces a
responsive snap with minimal oscillation β close to Apple's standard bottom sheet feel. The
velocity from DragEndDetails feeds directly into the
SpringSimulation, so a fast fling overshoots slightly before settling, while a slow
release glides smoothly to the nearest snap point. For a deeper look at physics-based animations,
review the physics simulation cookbook.
5. Custom Page Route Transitions
The default MaterialPageRoute slide-from-right works for
standard navigation, but matching the transition to the navigation context creates a stronger
spatial model in the user's mind. Modals should fade and scale up. Settings should slide from
the bottom. Back navigation should reverse the enter transition. Here are three production-ready
patterns using
PageRouteBuilder:
// 1. Fade + Scale β for detail screens and modals
class FadeScalePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
FadeScalePageRoute({required this.page})
: super(
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 300),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, __, child) {
final curved = CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween(begin: 0.92, end: 1.0).animate(curved),
child: child,
),
);
},
);
}
// 2. Shared Axis β for forward navigation flows
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
SharedAxisPageRoute({required this.page})
: super(
transitionDuration: const Duration(milliseconds: 350),
reverseTransitionDuration: const Duration(milliseconds: 300),
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, animation, secondaryAnimation, child) {
final fadeIn = Tween(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: animation,
curve: const Interval(0.3, 1.0),
),
);
final slideIn = Tween<Offset>(
begin: const Offset(0.1, 0.0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeOutCubic,
));
return FadeTransition(
opacity: fadeIn,
child: SlideTransition(position: slideIn, child: child),
);
},
);
}
// 3. Slide Up β for full-screen modals
class SlideUpPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
SlideUpPageRoute({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,
);
},
);
}
// Navigator usage
Navigator.push(context, FadeScalePageRoute(page: const ProductDetailScreen()));
// go_router usage
GoRoute(
path: '/product/:id',
pageBuilder: (context, state) => CustomTransitionPage(
child: ProductDetailScreen(id: state.pathParameters['id']!),
transitionsBuilder: (_, animation, __, child) {
final curved = CurvedAnimation(
parent: animation, curve: Curves.easeOutCubic,
);
return FadeTransition(
opacity: curved,
child: ScaleTransition(
scale: Tween(begin: 0.92, end: 1.0).animate(curved),
child: child,
),
);
},
),
)Package these as reusable route classes in a shared/routes/ directory. The FadeScale
transition works beautifully for detail screens because the slight scale-up creates a sense of
the content coming forward from the grid. For go_router users, the
CustomTransitionPage wrapper integrates cleanly with declarative routing. The
animations
package from the Flutter team provides ready-made Material motion transitions if you prefer
battle-tested implementations. Match the transition to the navigation semantic β forward
flows use SharedAxis, overlays use SlideUp, and detail views use FadeScale β and your app's
spatial model becomes intuitively clear.
6. Hero Detail Flow
Hero animations create a visual bridge between a browse screen and a detail screen. The user taps a product card, and the image physically flies from its grid position to the detail header β reinforcing the spatial relationship between the two screens. This is the single most impactful animation for e-commerce, photo galleries, and social feed apps. See the hero animations guide for the official walkthrough.
// Hero flow: Product grid card (source screen)
class ProductGridCard extends StatelessWidget {
final Product product;
const ProductGridCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => Navigator.push(
context,
FadeScalePageRoute(
page: ProductDetailScreen(product: product),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product-image-${product.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(
product.imageUrl,
fit: BoxFit.cover,
width: double.infinity,
height: 180,
),
),
),
const SizedBox(height: 8),
Hero(
tag: 'product-title-${product.id}',
child: Material(
color: Colors.transparent,
child: Text(
product.name,
style: Theme.of(context).textTheme.titleMedium,
),
),
),
Hero(
tag: 'product-price-${product.id}',
child: Material(
color: Colors.transparent,
child: Text(
'\$${product.price.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
);
}
}
// Product detail screen (destination)
class ProductDetailScreen extends StatelessWidget {
final Product product;
const ProductDetailScreen({super.key, required this.product});
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverAppBar(
expandedHeight: 350,
pinned: true,
flexibleSpace: FlexibleSpaceBar(
background: Hero(
tag: 'product-image-${product.id}',
child: Image.network(
product.imageUrl, fit: BoxFit.cover,
),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Hero(
tag: 'product-title-${product.id}',
child: Material(
color: Colors.transparent,
child: Text(
product.name,
style: Theme.of(context).textTheme.headlineMedium,
),
),
),
const SizedBox(height: 8),
Hero(
tag: 'product-price-${product.id}',
child: Material(
color: Colors.transparent,
child: Text(
'\$${product.price.toStringAsFixed(2)}',
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.primary,
),
),
),
),
const SizedBox(height: 24),
Text(product.description),
],
),
),
),
],
),
);
}
}Three Hero widgets (image, title, price) fly simultaneously between screens. The Material
wrapper on text Heroes prevents the default yellow-underline issue during flight. Use
flightShuttleBuilder for advanced customisation β
for example, showing a different widget resolution during the flight, or adding a subtle shadow
that appears only during transit. Combine Hero with the FadeScale page route from SectionΒ 5
for a polished drill-down interaction.
7. Skeleton-to-Content State Transition
Combining the shimmer skeleton from SectionΒ 1 with a smooth cross-fade to the real content creates professional loading UX. The key is not just switching states β it is animating the transition so the skeleton dissolves into the actual content rather than jumping abruptly.
// Skeleton-to-content cross-fade with AnimatedSwitcher
class ContentLoader extends StatelessWidget {
final bool isLoading;
final Widget skeleton;
final Widget content;
const ContentLoader({
super.key,
required this.isLoading,
required this.skeleton,
required this.content,
});
@override
Widget build(BuildContext context) {
return AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
switchInCurve: Curves.easeOut,
switchOutCurve: Curves.easeIn,
transitionBuilder: (child, animation) {
return FadeTransition(opacity: animation, child: child);
},
layoutBuilder: (currentChild, previousChildren) {
return Stack(
alignment: Alignment.topCenter,
children: [
...previousChildren,
if (currentChild != null) currentChild,
],
);
},
child: isLoading
? KeyedSubtree(
key: const ValueKey('skeleton'),
child: skeleton,
)
: KeyedSubtree(
key: const ValueKey('content'),
child: content,
),
);
}
}
// Usage
ContentLoader(
isLoading: _isLoading,
skeleton: const ShimmerSkeleton(),
content: ProductList(products: _products),
)The AnimatedSwitcher detects the widget change via the
ValueKey and cross-fades between skeleton and content. The custom
layoutBuilder stacks the outgoing skeleton behind the incoming content so both
are visible during the transition. Without the KeyedSubtree wrappers,
AnimatedSwitcher will not detect the change if both the skeleton and content are the
same widget type. Combine this with your
state management solution to drive the isLoading flag from your
data layer.
β‘ Pro Tip: AnimatedSwitcher vs AnimatedCrossFade
AnimatedCrossFade also handles two-state transitions
but uses layout animation for size changes, which can cause janky resizing. For loading states
where skeleton and content have different heights,
AnimatedSwitcher with a Stack layout builder gives smoother results
because it overlays rather than resizes.
8. Toast & Snackbar Animations
Flutter's built-in ScaffoldMessenger handles basic snackbars,
but custom toast notifications with controlled entrance/exit animations give you full creative
control. This pattern creates an overlay-based toast that slides in from the top with a spring
bounce and auto-dismisses after a configurable delay.
// Custom overlay toast with spring entrance
class ToastOverlay {
static OverlayEntry? _currentEntry;
static AnimationController? _currentController;
static void show(
BuildContext context, {
required String message,
Duration displayDuration = const Duration(seconds: 3),
Color backgroundColor = const Color(0xFF323232),
}) {
_currentEntry?.remove();
_currentController?.dispose();
final overlay = Overlay.of(context);
final controller = AnimationController(
vsync: overlay,
duration: const Duration(milliseconds: 600),
);
_currentController = controller;
final slideAnimation = Tween<Offset>(
begin: const Offset(0, -1.5),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: Curves.elasticOut,
));
final fadeAnimation = Tween<double>(
begin: 0.0, end: 1.0,
).animate(CurvedAnimation(
parent: controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOut),
));
late final OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => Positioned(
top: MediaQuery.of(context).padding.top + 16,
left: 16, right: 16,
child: SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: fadeAnimation,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(12),
color: backgroundColor,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 12,
),
child: Text(
message,
style: const TextStyle(
color: Colors.white, fontSize: 14,
),
),
),
),
),
),
),
);
_currentEntry = entry;
overlay.insert(entry);
controller.forward();
Future.delayed(displayDuration, () {
if (_currentEntry == entry) {
controller.reverse().then((_) {
entry.remove();
controller.dispose();
});
}
});
}
}
// Usage
ToastOverlay.show(context, message: 'Item added to cart');The Curves.elasticOut gives the toast a satisfying bounce on entry.
The fade starts in the first half of the animation (via
Interval(0.0, 0.5)) so the toast is fully opaque before the
slide settles. Using an Overlay instead of
ScaffoldMessenger means the toast renders above everything, including dialogs and
bottom sheets. For a more feature-complete solution with queue management and customizable
positioning, see the fluttertoast or
another_flushbar
packages.
9. Animated Search Bar Expand/Collapse
A search icon that expands into a full text field on tap is an elegant pattern for apps with limited app bar space. The animation needs to feel crisp β the icon fades out while the text field slides in, and the keyboard appears in sync with the expansion.
// Animated expanding search bar
class AnimatedSearchBar extends StatefulWidget {
final ValueChanged<String> onSearch;
const AnimatedSearchBar({super.key, required this.onSearch});
@override
State<AnimatedSearchBar> createState() => _AnimatedSearchBarState();
}
class _AnimatedSearchBarState extends State<AnimatedSearchBar>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _widthAnimation;
late final Animation<double> _iconFade;
late final Animation<double> _fieldFade;
final _focusNode = FocusNode();
final _textController = TextEditingController();
bool _isExpanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 350),
);
_widthAnimation = Tween<double>(begin: 48, end: 280).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic),
);
_iconFade = Tween<double>(begin: 1.0, end: 0.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
),
);
_fieldFade = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.3, 1.0, curve: Curves.easeOut),
),
);
}
void _toggle() {
if (_isExpanded) {
_controller.reverse();
_focusNode.unfocus();
_textController.clear();
} else {
_controller.forward();
Future.delayed(const Duration(milliseconds: 150), () {
_focusNode.requestFocus();
});
}
setState(() => _isExpanded = !_isExpanded);
}
@override
void dispose() {
_controller.dispose();
_focusNode.dispose();
_textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, _) {
return Container(
width: _widthAnimation.value,
height: 48,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(24),
),
child: Stack(
alignment: Alignment.centerLeft,
children: [
Opacity(
opacity: _iconFade.value,
child: IconButton(
icon: const Icon(Icons.search),
onPressed: _toggle,
),
),
if (_isExpanded || _controller.isAnimating)
Opacity(
opacity: _fieldFade.value,
child: Padding(
padding: const EdgeInsets.only(left: 16, right: 48),
child: TextField(
controller: _textController,
focusNode: _focusNode,
onSubmitted: widget.onSearch,
decoration: const InputDecoration(
hintText: 'Search...',
border: InputBorder.none,
),
),
),
),
if (_isExpanded || _controller.isAnimating)
Positioned(
right: 4,
child: Opacity(
opacity: _fieldFade.value,
child: IconButton(
icon: const Icon(Icons.close),
onPressed: _toggle,
),
),
),
],
),
);
},
);
}
}The icon fade-out (Interval(0.0, 0.4)) and field fade-in
(Interval(0.3, 1.0)) overlap, creating a smooth cross-dissolve during the width
expansion. The delayed requestFocus at 150Β ms ensures the keyboard appears
after the expansion has visually started, preventing a jarring layout jump. This pattern uses one
AnimationController for three synchronised animations β the same
Interval staggering technique documented in the
staggered animations guide.
10. Custom Pull-to-Refresh
The default RefreshIndicator works, but a custom
pull-to-refresh with your brand's animation creates a memorable interaction. This pattern
builds a physics-based indicator with a
SpringSimulation bounce-back and a rubber-band overscroll
effect similar to iOS.
// Custom pull-to-refresh with spring physics
class CustomPullToRefresh extends StatefulWidget {
final Future<void> Function() onRefresh;
final Widget child;
const CustomPullToRefresh({
super.key,
required this.onRefresh,
required this.child,
});
@override
State<CustomPullToRefresh> createState() => _CustomPullToRefreshState();
}
class _CustomPullToRefreshState extends State<CustomPullToRefresh>
with SingleTickerProviderStateMixin {
late final AnimationController _springController;
double _dragOffset = 0.0;
bool _isRefreshing = false;
static const double _triggerDistance = 100.0;
@override
void initState() {
super.initState();
_springController = AnimationController(vsync: this);
}
void _onDragUpdate(double primaryDelta) {
if (_isRefreshing) return;
setState(() {
// Rubber-band: drag slows past trigger distance
_dragOffset += primaryDelta *
(_dragOffset < _triggerDistance ? 1.0 : 0.4);
_dragOffset = _dragOffset.clamp(0.0, _triggerDistance * 2);
});
}
void _onDragEnd() async {
if (_dragOffset >= _triggerDistance && !_isRefreshing) {
setState(() => _isRefreshing = true);
_animateTo(_triggerDistance);
await widget.onRefresh();
if (mounted) {
setState(() => _isRefreshing = false);
_animateTo(0.0);
}
} else {
_animateTo(0.0);
}
}
void _animateTo(double target) {
final spring = SpringDescription(mass: 1, stiffness: 400, damping: 28);
final simulation = SpringSimulation(
spring, _dragOffset, target, 0,
);
_springController.value = 0;
_springController.addListener(_springUpdate);
_springController.animateWith(simulation);
}
void _springUpdate() {
setState(() => _dragOffset =
_springController.value.clamp(0.0, _triggerDistance * 2));
}
@override
void dispose() {
_springController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
Positioned(
top: 0, left: 0, right: 0,
child: SizedBox(
height: _dragOffset.clamp(0, _triggerDistance),
child: Center(
child: _isRefreshing
? const SizedBox(
width: 24, height: 24,
child: CircularProgressIndicator(strokeWidth: 2.5),
)
: Transform.rotate(
angle: (_dragOffset / _triggerDistance) * 3.14159 * 2,
child: Icon(
Icons.refresh,
color: _dragOffset >= _triggerDistance
? Theme.of(context).colorScheme.primary
: Colors.grey,
),
),
),
),
),
Transform.translate(
offset: Offset(0, _dragOffset),
child: NotificationListener<ScrollNotification>(
onNotification: (notification) {
if (notification is ScrollUpdateNotification &&
notification.metrics.pixels <= 0) {
_onDragUpdate(-notification.scrollDelta ?? 0);
}
if (notification is ScrollEndNotification) {
_onDragEnd();
}
return false;
},
child: widget.child,
),
),
],
);
}
}The rubber-band effect (multiplying drag delta by 0.4 past the trigger distance) mimics iOS's
overscroll behavior. The refresh icon rotates proportionally to drag distance using
Transform.rotate, providing continuous visual feedback.
Once past the trigger threshold, the icon colour switches to the primary colour as an affordance
that releasing will trigger refresh. For a drop-in alternative with Cupertino styling, see the
CupertinoSliverRefreshControl.
11. Onboarding Page Animations
Onboarding flows need smooth page transitions with content that enters in a staggered sequence
within each page. The PageView widget provides the swipe gesture
and page-snapping for free. Combined with a
PageController's page value, you can drive parallax effects
and content stagger animations tied directly to the swipe position β no separate
AnimationController needed.
// Onboarding with parallax and staggered content
class OnboardingScreen extends StatefulWidget {
const OnboardingScreen({super.key});
@override
State<OnboardingScreen> createState() => _OnboardingScreenState();
}
class _OnboardingScreenState extends State<OnboardingScreen> {
final _pageController = PageController();
double _currentPage = 0.0;
final _pages = const [
OnboardingData(
title: 'Track Your Habits',
subtitle: 'Build consistency with daily tracking and streaks',
icon: Icons.track_changes,
),
OnboardingData(
title: 'Set Smart Goals',
subtitle: 'AI-powered suggestions based on your patterns',
icon: Icons.flag_outlined,
),
OnboardingData(
title: 'See Your Progress',
subtitle: 'Beautiful charts that motivate you to keep going',
icon: Icons.insights,
),
];
@override
void initState() {
super.initState();
_pageController.addListener(() {
setState(() => _currentPage = _pageController.page ?? 0);
});
}
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Column(
children: [
Expanded(
child: PageView.builder(
controller: _pageController,
itemCount: _pages.length,
itemBuilder: (context, index) {
return _OnboardingPage(
data: _pages[index],
pageOffset: _currentPage - index,
);
},
),
),
Padding(
padding: const EdgeInsets.only(bottom: 32),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(_pages.length, (index) {
final distance = (_currentPage - index).abs();
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.symmetric(horizontal: 4),
width: distance < 0.5 ? 24 : 8,
height: 8,
decoration: BoxDecoration(
color: distance < 0.5
? Theme.of(context).colorScheme.primary
: Colors.grey[300],
borderRadius: BorderRadius.circular(4),
),
);
}),
),
),
],
),
),
);
}
}
class _OnboardingPage extends StatelessWidget {
final OnboardingData data;
final double pageOffset;
const _OnboardingPage({required this.data, required this.pageOffset});
@override
Widget build(BuildContext context) {
final iconOffset = pageOffset * 0.6;
final titleOffset = pageOffset * 0.3;
final subtitleOffset = pageOffset * 0.15;
final opacity = (1.0 - pageOffset.abs()).clamp(0.0, 1.0);
final screenWidth = MediaQuery.of(context).size.width;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Transform.translate(
offset: Offset(iconOffset * screenWidth, 0),
child: Opacity(
opacity: opacity,
child: Icon(
data.icon, size: 120,
color: Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(height: 48),
Transform.translate(
offset: Offset(titleOffset * screenWidth, 0),
child: Opacity(
opacity: opacity,
child: Text(
data.title,
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center,
),
),
),
const SizedBox(height: 16),
Transform.translate(
offset: Offset(subtitleOffset * screenWidth, 0),
child: Opacity(
opacity: opacity,
child: Text(
data.subtitle,
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color: Colors.grey[600],
),
textAlign: TextAlign.center,
),
),
),
],
),
);
}
}
class OnboardingData {
final String title;
final String subtitle;
final IconData icon;
const OnboardingData({
required this.title,
required this.subtitle,
required this.icon,
});
}The parallax comes from multiplying pageOffset by different factors for each layer:
the icon moves at 60% of swipe speed, the title at 30%, and the subtitle at 15%. This creates
depth without any AnimationController β the
PageController's continuous page value drives everything. The
AnimatedContainer page dots expand from 8px to 24px when
active, using implicit animation for a smooth indicator transition. For more polished onboarding,
add a smooth_page_indicator and
Lottie illustrations
instead of Icons.
12. FAB Reveal & Menu Expansion
A floating action button that expands into a radial menu of options is a compact way to offer
multiple actions without cluttering the UI. The animation uses staggered
Intervals for each menu item, with the main FAB rotating
to indicate the state change.
// FAB with staggered radial menu expansion
class ExpandableFab extends StatefulWidget {
final List<FabMenuItem> items;
const ExpandableFab({super.key, required this.items});
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
bool _isOpen = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 400),
);
}
void _toggle() {
setState(() => _isOpen = !_isOpen);
_isOpen ? _controller.forward() : _controller.reverse();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return SizedBox(
width: 200, height: 300,
child: Stack(
alignment: Alignment.bottomRight,
children: [
// Scrim
if (_isOpen || _controller.isAnimating)
GestureDetector(
onTap: _toggle,
child: FadeTransition(
opacity: _controller,
child: Container(color: Colors.black26),
),
),
// Menu items (staggered pop-in)
...widget.items.asMap().entries.map((entry) {
final index = entry.key;
final item = entry.value;
final itemCount = widget.items.length;
final start = (index / itemCount) * 0.6;
final end = start + 0.4;
final scale = Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOutBack),
),
);
final translate = Tween<double>(
begin: 0.0,
end: -((index + 1) * 60.0),
).animate(CurvedAnimation(
parent: _controller,
curve: Interval(start, end, curve: Curves.easeOutCubic),
));
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: Offset(0, translate.value),
child: Transform.scale(
scale: scale.value,
child: child,
),
);
},
child: FloatingActionButton.small(
heroTag: 'fab_item_$index',
onPressed: () {
_toggle();
item.onPressed();
},
tooltip: item.label,
child: Icon(item.icon),
),
);
}),
// Main FAB with rotation
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 0.785,
child: child,
);
},
child: FloatingActionButton(
onPressed: _toggle,
child: const Icon(Icons.add),
),
),
],
),
);
}
}
class FabMenuItem {
final IconData icon;
final String label;
final VoidCallback onPressed;
const FabMenuItem({
required this.icon,
required this.label,
required this.onPressed,
});
}
// Usage
ExpandableFab(
items: [
FabMenuItem(icon: Icons.camera_alt, label: 'Camera',
onPressed: () => _openCamera()),
FabMenuItem(icon: Icons.photo_library, label: 'Gallery',
onPressed: () => _openGallery()),
FabMenuItem(icon: Icons.edit, label: 'Write',
onPressed: () => _openEditor()),
],
)Each menu item uses Curves.easeOutBack for the scale animation,
which overshoots slightly before settling β creating a satisfying pop-in effect. The stagger
is calculated by dividing the first 60% of the animation evenly among items, so with 3 items they
start at 0.0, 0.2, and 0.4 respectively, each taking 0.4 units of the total duration. The main
FAB rotates 45 degrees (turning the + into an Γ symbol) as a clear visual indicator of the
toggle state. For accessibility, the tooltip on each mini-FAB ensures screen readers
announce the action β see the
Flutter accessibility guide.
Performance Checklist for All 12 Patterns
Every animation pattern in this guide follows these performance rules. Before shipping any animated screen, verify each item. We use this as a code review gate at Flutter Studio β see the official Flutter performance best practices for the full framework documentation.
- β AnimatedBuilder uses the
childparameter β Static subtrees are built once and passed through, not rebuilt every frame. This alone prevents the most common animation jank issue. - β Transform and Opacity over layout properties β Animating
Transform.translate,Transform.scale, andOpacitytriggers only compositing, not layout. Animatingwidth,height, orpaddingtriggers expensive relayouts on every frame. - β RepaintBoundary isolates animated widgets β Especially for
shimmer skeletons and continuously-animating elements. Without it, one widget's repaint
contaminates the entire list. Read more about
RepaintBoundary. - β All controllers disposed β Every
AnimationControlleris disposed indispose(). Leaked controllers causesetState called after disposeerrors and memory leaks. - β SingleTickerProviderStateMixin for single controllers β Only use
TickerProviderStateMixinwhen you genuinely have multiple independent controllers. The single variant is more memory-efficient. - β Accessibility: reduced motion respected β Check
MediaQuery.disableAnimationsOf(context)and either skip animations or set duration to zero. The Flutter accessibility guide covers this in detail. - β Profiled on mid-range hardware β Use
Flutter
DevTools Performance overlay on a budget device. No frames
should exceed 16Β ms (60Β fps). Test with
showPerformanceOverlay: trueinMaterialApp. - β Animation durations match UX intent β Enter transitions:
300β400Β ms with
easeOut. Exit transitions: 200β300Β ms witheaseIn. State changes: 200β250Β ms witheaseInOut. Anything over 600Β ms feels sluggish.
π Related Articles
- Flutter Animations Masterclass: From AnimationController to Production-Ready Motion
- Flutter Performance Optimization: A Complete Guide
- Beautiful Forms in Flutter: A Complete Guide to Professional Form Design
- BLoC vs Riverpod in 2026: The Definitive Flutter State Management Comparison
- Top 10 Flutter Packages Every Developer Needs in 2026
- Flutter Clean Architecture: A Practical Guide
- Flutter for Web: Building Production-Grade Web Apps
- Flutter App Security: A Complete Guide
π Need Animation Expertise for Your App?
We have shipped micro-interactions, complex onboarding flows, and custom physics animations for 50+ client apps. If your project needs polished motion design, let's discuss your project. Check our Flutter development services.
Frequently Asked Questions
What are the most common Flutter animation patterns used in production apps?
The most common production patterns include shimmer loading skeletons, staggered list enter
animations, bottom sheet spring transitions, animated tab indicators,
Hero shared-element transitions, toast/snackbar slide-ins, search
bar expand-collapse, empty-to-content state transitions, pull-to-refresh with custom physics,
and onboarding page-view animations. Each pattern uses specific Flutter animation APIs suited
to its interaction model β from implicit
AnimatedSwitcher for state changes to explicit
AnimationController with
SpringSimulation for gesture-driven motion.
How do I create a shimmer loading effect in Flutter?
Create a shimmer effect using the shimmer package from pub.dev, or build one from scratch with a
LinearGradient inside a
ShaderMask widget, animated by an
AnimationController with repeat(). The gradient slides
horizontally across placeholder shapes that match your content layout. Wrap each shimmer
card in a RepaintBoundary to isolate repaints when displaying multiple
skeletons in a list.
What is the best way to animate list items in Flutter?
For staggered list enter animations, use a single AnimationController with
Interval curves calculated per item index. Each item
gets a SlideTransition and FadeTransition with an offset interval.
For dynamic lists where items are added or removed, use
AnimatedList or
SliverAnimatedList which provide built-in insert and
remove animations with AnimatedListState.
How do I build an animated bottom sheet in Flutter?
Build custom animated bottom sheets using
DraggableScrollableSheet for
gesture-driven behavior, or AnimationController with
SpringSimulation for physics-based snap points. The key
is connecting the drag gesture position to the animation value, then using spring physics
(with SpringDescription) to snap to open,
half-open, or closed states when the user releases.
How do I animate page transitions between screens in Flutter?
Create custom page transitions by extending
PageRouteBuilder and implementing the
transitionsBuilder callback. Common patterns include
FadeTransition for modals,
SlideTransition for directional navigation, and
combined scale-plus-fade for detail screens. Use
CurvedAnimation to apply easing curves. For named
routes with go_router, provide a custom pageBuilder in your
GoRoute configuration.
How do I optimize Flutter animations to avoid jank and dropped frames?
Key optimizations: 1) Use
AnimatedBuilder with the child
parameter to avoid rebuilding static subtrees. 2) Animate
Transform and Opacity properties instead of layout properties
like width or padding. 3) Wrap animated widgets in
RepaintBoundary to isolate repaints. 4) Use
const constructors for child widgets. 5) Dispose all
AnimationControllers in dispose(). 6) Profile on mid-range
devices using Flutter DevTools Performance overlay to detect frames exceeding
16Β ms.