Animations are perceived performance. An app that loads in 1.4 seconds with a shimmer skeleton feels faster than an app that loads in 0.9 seconds with a blank white screen. A button that springs back on release feels alive. A list that staggers into view feels intentional. This is the set of ten Flutter animation patterns I copy into every new app in 2026, each one tested on Impeller across iOS and Android, with full Dart you can paste into your widget today.
The 2026 baseline is different from two years ago. Impeller is the default renderer on both iOS and Android, so the old jank spikes from shader compilation are gone. ProMotion at 120Hz is standard on iPhone Pro and most Android flagships, which means animations have to run at 8.3ms per frame, not 16.6ms. The rules below assume that target.
Animation choices in 2026
Flutter ships four ways to animate. Pick the right one for the job and you will not spend a weekend rebuilding a tap effect.
| Approach | Best for | Performance | Designer handoff |
|---|---|---|---|
| Implicit (AnimatedFoo) | Single property tweens triggered by state change | Excellent, vsync-bound | None needed, code-driven |
| Explicit (AnimationController) | Coordinated multi-property timelines, staggered, looping | Excellent if used with AnimatedBuilder | None, but more code |
| Rive 2 | Interactive state machines, character rigs, gesture-driven art | Excellent, GPU-accelerated | Designer ships .riv from Rive editor |
| Lottie | One-shot After Effects exports, splash, success states | Good but heavier than Rive on long loops | Designer ships .json from AE Bodymovin |
Default rule: use implicit when one property changes, explicit when several change in concert, Rive for anything interactive that a designer should own, and Lottie for one-off pre-rendered art.
Pattern 1: Hero transitions between screens
The single highest-leverage animation in any app. A product image grows from a list tile into a full detail header. Two widgets, same tag, Flutter does the rest.
// list_tile.dart
GestureDetector(
onTap: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => ProductDetail(product: p)),
),
child: Hero(
tag: 'product-image-${p.id}',
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.network(p.imageUrl, width: 72, height: 72, fit: BoxFit.cover),
),
),
);
// product_detail.dart
Hero(
tag: 'product-image-${product.id}',
child: Image.network(product.imageUrl, fit: BoxFit.cover),
);Two rules that prevent 90 percent of hero bugs: keep the tag globally unique per item, and do not wrap the Hero in widgets that change layout during the flight (no Padding that animates, no AnimatedContainer as parent). If you need a custom flight, passflightShuttleBuilder rather than wrapping the Hero.
Pattern 2: Staggered list reveal on screen entry
Items fade up from below in a 60ms cascade. Used by every premium app on first paint of a feed. The trick is one AnimationController for the whole list, not one per item.
class StaggeredList extends StatefulWidget {
const StaggeredList({super.key, required this.items});
final List<Widget> items;
@override
State<StaggeredList> createState() => _StaggeredListState();
}
class _StaggeredListState extends State<StaggeredList> with SingleTickerProviderStateMixin {
late final AnimationController _c = AnimationController(
vsync: this,
duration: Duration(milliseconds: 220 + widget.items.length * 60),
)..forward();
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: widget.items.length,
itemBuilder: (context, i) {
final start = (i * 60) / _c.duration!.inMilliseconds;
final end = (start + 0.35).clamp(0.0, 1.0);
final curve = CurvedAnimation(parent: _c, curve: Interval(start, end, curve: Curves.easeOutCubic));
return AnimatedBuilder(
animation: curve,
builder: (context, child) => Opacity(
opacity: curve.value,
child: Transform.translate(offset: Offset(0, (1 - curve.value) * 16), child: child),
),
child: widget.items[i],
);
},
);
}
}Pattern 3: Shimmer skeleton loaders
Skeletons make a 1.5-second network call feel like 0.7 seconds. Build it without a package using ShaderMask and a looping controller, or drop in the shimmerpackage. Code below is package-free so you can audit every pixel.
class Shimmer extends StatefulWidget {
const Shimmer({super.key, required this.child});
final Widget child;
@override
State<Shimmer> createState() => _ShimmerState();
}
class _ShimmerState extends State<Shimmer> with SingleTickerProviderStateMixin {
late final _c = AnimationController(vsync: this, duration: const Duration(milliseconds: 1400))..repeat();
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _c,
builder: (context, child) => ShaderMask(
blendMode: BlendMode.srcATop,
shaderCallback: (bounds) => LinearGradient(
begin: Alignment(-1 + _c.value * 2, 0),
end: Alignment(0 + _c.value * 2, 0),
colors: const [Color(0x22FFFFFF), Color(0x55FFFFFF), Color(0x22FFFFFF)],
).createShader(bounds),
child: child,
),
child: widget.child,
);
}
}
// Usage: wrap a grey Container that matches the eventual layout.
Shimmer(child: Container(height: 16, width: 140, color: Colors.white12));Pattern 4: Animated bottom sheet with drag-to-dismiss
A modern modal that follows the finger, snaps to two heights, and dismisses on a hard down fling. DraggableScrollableSheet handles 80 percent of this for free, then a tiny fade overlay sells the polish.
Future<T?> showAppSheet<T>(BuildContext context, Widget child) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
barrierColor: Colors.black54,
builder: (_) => DraggableScrollableSheet(
initialChildSize: 0.55,
minChildSize: 0.3,
maxChildSize: 0.95,
expand: false,
snap: true,
snapSizes: const [0.55, 0.95],
builder: (context, scrollController) => Container(
decoration: const BoxDecoration(
color: Color(0xFF111114),
borderRadius: BorderRadius.vertical(top: Radius.circular(24)),
),
child: SingleChildScrollView(controller: scrollController, child: child),
),
),
);
}Pattern 5: Parallax scrolling header (CustomScrollView + SliverAppBar)
A cover image that scales and fades as the user scrolls. The cleanest way is aSliverAppBar with FlexibleSpaceBar and aCollapseMode.parallax.
CustomScrollView(
slivers: [
SliverAppBar.large(
expandedHeight: 280,
pinned: true,
stretch: true,
flexibleSpace: FlexibleSpaceBar(
collapseMode: CollapseMode.parallax,
stretchModes: const [StretchMode.zoomBackground, StretchMode.fadeTitle],
title: const Text('Mount Rainier'),
background: Image.network(coverUrl, fit: BoxFit.cover),
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, i) => ListTile(title: Text('Item $i')),
childCount: 40,
),
),
],
);For a deeper parallax effect (background moves slower than foreground), wrap the background image in a Transform.translate driven by a scroll listener at one third the scroll velocity.
Pattern 6: Spring physics on button taps
The pressable feel that iOS apps nailed in 2014 and Material apps still get wrong in 2026. Use SpringSimulation from flutter/physics so the release looks like a real spring, not a tween.
class SpringTap extends StatefulWidget {
const SpringTap({super.key, required this.child, required this.onTap});
final Widget child;
final VoidCallback onTap;
@override
State<SpringTap> createState() => _SpringTapState();
}
class _SpringTapState extends State<SpringTap> with SingleTickerProviderStateMixin {
late final _c = AnimationController.unbounded(vsync: this, value: 1);
static const _spring = SpringDescription(mass: 1, stiffness: 220, damping: 18);
void _down(_) => _c.animateTo(0.94, duration: const Duration(milliseconds: 80), curve: Curves.easeOut);
void _up(_) => _c.animateWith(SpringSimulation(_spring, _c.value, 1, 0));
void _cancel() => _up(null);
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: _down,
onTapUp: (d) { _up(d); widget.onTap(); },
onTapCancel: _cancel,
child: AnimatedBuilder(
animation: _c,
builder: (_, child) => Transform.scale(scale: _c.value, child: child),
child: widget.child,
),
);
}
}Pattern 7: AnimatedSwitcher for in-place content swaps
When a button changes from Sign in to a CircularProgressIndicator to a checkmark, wrap it in AnimatedSwitcher and give each state a uniqueValueKey. The widget cross-fades and scales between them automatically.
AnimatedSwitcher(
duration: const Duration(milliseconds: 240),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, anim) => FadeTransition(
opacity: anim,
child: ScaleTransition(scale: Tween(begin: 0.92, end: 1.0).animate(anim), child: child),
),
child: switch (state) {
AuthState.idle => const Text('Sign in', key: ValueKey('idle')),
AuthState.loading => const SizedBox(
key: ValueKey('loading'),
width: 22, height: 22,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
),
AuthState.success => const Icon(Icons.check, key: ValueKey('done'), color: Colors.white),
},
);Pattern 8: Page transitions via PageRouteBuilder
The default MaterialPageRoute uses platform-specific transitions. Override when you want a vertical slide for a modal-style detail screen, or a fade for a tab pivot.
Route<T> verticalSlideRoute<T>(Widget page) {
return PageRouteBuilder<T>(
transitionDuration: const Duration(milliseconds: 320),
reverseTransitionDuration: const Duration(milliseconds: 220),
opaque: false,
pageBuilder: (_, __, ___) => page,
transitionsBuilder: (_, anim, __, child) {
final curved = CurvedAnimation(parent: anim, curve: Curves.easeOutCubic);
return SlideTransition(
position: Tween(begin: const Offset(0, 0.08), end: Offset.zero).animate(curved),
child: FadeTransition(opacity: curved, child: child),
);
},
);
}
Navigator.of(context).push(verticalSlideRoute(const ProductDetail()));If you are on go_router, attach the builder via pageBuilder on the route and return a CustomTransitionPage with the same transition.
Pattern 9: Pull-to-refresh with custom indicator
The default RefreshIndicator is fine. For brand polish, swap in a custom indicator with a Rive file or a hand-rolled CustomPainter. Below is the minimal hand-rolled version that animates a dot ring as the user pulls.
RefreshIndicator(
onRefresh: () async {
await context.read<FeedBloc>().refresh();
},
edgeOffset: 0,
displacement: 56,
strokeWidth: 2.4,
color: Theme.of(context).colorScheme.primary,
backgroundColor: Theme.of(context).colorScheme.surfaceContainerHighest,
child: ListView.builder(
physics: const AlwaysScrollableScrollPhysics(),
itemBuilder: (context, i) => FeedItem(item: items[i]),
itemCount: items.length,
),
);For a fully custom indicator, use CustomScrollView with aCupertinoSliverRefreshControl on iOS and a SliverToBoxAdapter on Android driven by a ScrollController. The pull offset becomes the input to your painter.
Pattern 10: Confetti on purchase success
The single best conversion moment in your app: the user just paid. Throw confetti. Use theconfetti package for the heavy lift, or roll your own withCustomPainter and a particle system if you need a custom shape.
class PurchaseSuccess extends StatefulWidget {
const PurchaseSuccess({super.key});
@override
State<PurchaseSuccess> createState() => _PurchaseSuccessState();
}
class _PurchaseSuccessState extends State<PurchaseSuccess> {
final _c = ConfettiController(duration: const Duration(seconds: 2));
@override
void initState() { super.initState(); _c.play(); }
@override
void dispose() { _c.dispose(); super.dispose(); }
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.topCenter,
children: [
const _SuccessCard(),
ConfettiWidget(
confettiController: _c,
blastDirectionality: BlastDirectionality.explosive,
numberOfParticles: 36,
maxBlastForce: 22,
minBlastForce: 8,
gravity: 0.25,
shouldLoop: false,
colors: const [Color(0xFF7C5CFF), Color(0xFF22D3EE), Color(0xFFFACC15)],
),
],
);
}
}Pair the confetti with a soft haptic via HapticFeedback.mediumImpact() and a one-shot success sound. The combo turns a payment screen into a moment users screenshot.
Lottie and Rive: when to drop in pre-made animations
Code-driven animations are great for interactive elements that respond to the app state. Lottie and Rive are better when a designer needs to own the animation.
- Rive 2 in 2026 is strictly better than Lottie for anything interactive. State machines let designers wire inputs (boolean, number) to animation states without shipping new code. Use Rive for onboarding mascots, animated icons that respond to taps, and any character art.
- Lottie still wins for one-shot designer-shipped JSON exported from After Effects via Bodymovin. Splash logos, success checkmarks, and confetti bursts that the motion designer already built in AE.
- Do not use Lottie loops on always-visible widgets. Each frame decodes a JSON tree and rebuilds paths. Rive renders directly on the GPU and uses 5 to 10x less CPU for the same effect.
// Rive
RiveAnimation.asset('assets/rive/mascot.riv', stateMachines: const ['Main']);
// Lottie one-shot
Lottie.asset('assets/lottie/success.json', repeat: false, onLoaded: (c) => _c.duration = c.duration);Performance: hitting 60 and 120fps with Impeller in 2026
Impeller is the default renderer on iOS since Flutter 3.10 and on Android since Flutter 3.27. The old shader-compilation jank is gone. The new constraints are different.
- Use AnimatedBuilder, not setState. Every
setStaterebuilds the entire widget subtree.AnimatedBuilderonly rebuilds the leaf and preserves the child via thechildparameter. - Set 120Hz explicitly on iOS. ProMotion requires
CADisableMinimumFrameDurationOnPhone=YESinInfo.plist. Without it your app runs at 60Hz on iPhone Pro even though the display is capable of 120Hz. On Android, Flutter picks up the device refresh rate automatically. - RepaintBoundary around heavy widgets. A shimmer wrapper, a Lottie loop, or a particle field should sit inside a
RepaintBoundaryso the rest of the screen does not re-rasterize every frame. - Profile in profile mode, not debug. Debug mode is 2 to 4x slower than release. Run
flutter run --profileand check the DevTools Performance tab. Anything over 8ms per frame on a 120Hz device is jank. - Avoid BackdropFilter on scrolling content. Blur is expensive even on Impeller. If the blurred surface stays static (a paywall card, a sheet header), wrap it in a
RepaintBoundaryso it gets snapshot-cached.
Anti-patterns to avoid
- Do not call setState inside addListener on an AnimationController. Use
AnimatedBuilderinstead.setStateper frame rebuilds the parent widget tree 60 to 120 times per second. - Do not animate via Timer.periodic. Timers do not sync with vsync, so you get tearing and missed frames. Always use an
AnimationControllertied to aTickerProvider. - Do not nest more than two AnimatedFoo widgets. Each one schedules its own ticker. Two is fine. Five is jank.
- Do not forget to dispose AnimationControllers. Leaks compound: a list with 200 cells that each spawn a controller without disposing them will pin 200 tickers to vsync forever.
- Do not animate
BoxShadowat high blur radius. Soft shadows are expensive every frame. If you must animate, pre-bake the lit and unlit shadow as two cached layers and cross-fade them. - Do not loop a Lottie that runs in the background. Pause it when the widget is offscreen via a
VisibilityDetectoror it will burn battery. - Do not ignore Reduce Motion. Check
MediaQuery.of(context).disableAnimationsand either skip the animation or cut its duration to zero. Required for accessibility compliance.
What The Flutter Kit ships
The Flutter Kit ships every pattern in this post as a reusable widget: a SpringTap button wrapper, a Shimmer skeleton helper, aStaggeredList mixin, a custom verticalSlideRoute wired into go_router, a parallax scroll header used on the profile and product screens, a drag-to-dismiss sheet helper, a confetti success screen for paywall conversion, and a Reduce Motion guard that auto-cuts durations to zero when the user has the OS setting on.
Every animation is profiled at 120Hz on iPhone 15 Pro and 90Hz on a Pixel 8, and the analytics layer logs every animation completion event to PostHog so you can prove the confetti screen actually lifts conversion.
$69 one-time, unlimited commercial projects. See every integration on the features page or jump to checkout.
Final recommendation
Pick three patterns from this post and ship them in your next release: spring tap on every primary button, a shimmer skeleton on your slowest-loading screen, and a confetti burst on purchase success. Those three alone will move your perceived-quality score more than any color palette refresh. Add staggered list reveal, hero transitions, and a parallax header when you have time to polish, and your app will feel like the apps people screenshot on Twitter.