Skill v1.0.1
currentAutomated scan100/100+6 new
version: "1.0.1" name: animations description: Best practices for Flutter animations using the built-in animation framework. Use when creating, modifying, or reviewing animations, transitions, motion, or animated widgets. Covers implicit animations, explicit animations, page transitions, and Material 3 motion tokens. when_to_use: > Also use for custom route transitions — CustomTransitionPage, a buildPage override on a GoRouteData subclass, or a Hero transition. Motion between routes is animation work even when the surrounding code is go_router. allowed-tools: Read,Glob,Grep argument-hint: "[file-or-directory]"
Animations
Flutter animation best practices using the built-in animation framework and Material 3 motion guidelines. No third-party animation libraries (Lottie, Rive, etc.).
Core Standards
Apply these standards to ALL animation work:
- Clarify visual intent when the request is ambiguous — when the developer says "add an animation" or "make it smoother" without specifying property, trigger, duration, or curve, ask before writing code. If the developer provides clear specs (e.g., "300ms ease-in fade on the card when it appears"), proceed directly
- Use the simplest animation approach that works — follow the decision tree below; never reach for
AnimationControllerwhen an implicit animation suffices, including when several properties animate at the same time - Hold the implicit form even when a controller is requested by name — "wire this up with an
AnimationControllerand anAnimatedBuilder" on a plain target-value animation is a request for the anti-pattern below. Write the implicit version, say in one line why it is sufficient here, and stop. Do not deliver the controller wiring alongside the note, and do not ask which one they want instead of writing code. If the developer reaffirms the controller after reading the reason, build it - Use Material 3 motion tokens for duration and easing — never hardcode arbitrary
DurationorCurvevalues - Extract animation constants — durations, curves, and offsets go in named constants or a centralized
AppMotionclass, not inline - Dispose controllers — every
AnimationControllermust be disposed in thedispose()method of theState - Use `SingleTickerProviderStateMixin` for one controller — use
TickerProviderStateMixinonly when the widget owns multiple controllers - Keep animated subtrees small — wrap only the widgets that change inside the animation builder, not entire widget trees
- Never animate layout-triggering properties in a tight loop — animating
width/heighton complex layouts causes expensive rebuilds; preferTransformorOpacitywhich operate on the compositing layer
Animation Decision Tree
Choose the simplest approach that meets the requirement:
Does the widget rebuild when the value changes?|YES --> Does the framework provide an AnimatedFoo widget?| || YES --> Use the implicit AnimatedFoo widget| | (AnimatedContainer, AnimatedOpacity, AnimatedAlign, etc.)| || NO --> Use TweenAnimationBuilder|NO --> Do you need fine-grained control?(repeat, reverse, sequence, listen to status)|YES --> Use AnimationController + AnimatedBuilder|NO --> Use TweenAnimationBuilder
Rule of thumb: if the animation is "set a target and let it animate there", use implicit. If the animation must play/pause/reverse/repeat on command, use explicit.
Animating two properties at once is still implicit. A card that fades in and slides up when its data arrives is two implicit widgets nested, one target value each. Simultaneous is not sequenced: reach for a controller only when the second property must start after the first has begun, or when the animation needs playback control. Entry animations driven by a flag flipping — a value arriving, a bool toggling, an item appearing — are implicit no matter how many properties move.
Material 3 Motion Tokens
Use Flutter's built-in Durations and Easing classes — never hardcode Duration(milliseconds: ...) or use Curves.* for new code. The framework constants align with the Material 3 motion specification; refer to the Flutter Durations and Easing class documentation for the full token list.
Centralized Motion Constants
Introduce an AppMotion class when the project uses animations across multiple features. For a single animation in the app, inline M3 tokens are sufficient.
abstract class AppMotion {// Standard transitionsstatic const Duration standardDuration = Durations.medium2;static const Curve standardCurve = Easing.standard;// Page transitionsstatic const Duration pageDuration = Durations.medium4;static const Curve pageEnterCurve = Easing.emphasizedDecelerate;static const Curve pageExitCurve = Easing.emphasizedAccelerate;// Fadesstatic const Duration fadeDuration = Durations.short3;static const Curve fadeCurve = Easing.standard;}
Implicit Animations
Use implicit animations when the widget rebuilds with new target values. The framework interpolates automatically. Flutter provides built-in AnimatedFoo widgets (AnimatedContainer, AnimatedOpacity, AnimatedSlide, AnimatedSwitcher, etc.) — use the one that matches the property being animated. When no built-in widget exists, use TweenAnimationBuilder.
Compose one AnimatedFoo per property when several move together. This is the entry-animation shape — a widget hidden until its data arrives, then fading in and sliding into place:
class SummaryCard extends StatelessWidget {const SummaryCard({required this.summary, super.key});final Summary? summary;@overrideWidget build(BuildContext context) {final hasData = summary != null;return AnimatedOpacity(opacity: hasData ? 1 : 0,duration: Durations.medium2,curve: Easing.standard,child: AnimatedSlide(offset: hasData ? Offset.zero : const Offset(0, 0.1),duration: Durations.medium2,curve: Easing.emphasizedDecelerate,child: Card(child: _SummaryContents(summary: summary)),),);}}
No StatefulWidget, no controller, no ticker, no dispose. Both properties animate off the same rebuild.
TweenAnimationBuilder
Use TweenAnimationBuilder when no built-in AnimatedFoo widget exists for your property, but you still want implicit-style "set and forget" animation.
TweenAnimationBuilder<double>(tween: Tween(begin: 0, end: isActive ? 1.0 : 0.0),duration: Durations.medium2,curve: Easing.standard,builder: (context, value, child) {return Transform.scale(scale: 0.8 + (0.2 * value),child: Opacity(opacity: value,child: child,),);},child: child, // child is not rebuilt — optimization)
The child parameter is critical: pass widgets that do not depend on the animated value to avoid unnecessary rebuilds.
Explicit Animations
Use explicit animations when you need control over playback: play, pause, reverse, repeat, or listen to animation status.
AnimationController Setup
class _MyWidgetState extends State<MyWidget>with SingleTickerProviderStateMixin {late final AnimationController _controller;late final Animation<double> _fadeAnimation;@overridevoid initState() {super.initState();_controller = AnimationController(duration: Durations.medium2,vsync: this,);_fadeAnimation = CurvedAnimation(parent: _controller,curve: Easing.standard,);}@overridevoid dispose() {_controller.dispose();super.dispose();}@overrideWidget build(BuildContext context) {return AnimatedBuilder(animation: _fadeAnimation,builder: (context, child) {return Opacity(opacity: _fadeAnimation.value,child: child,);},child: child, // static child — not rebuilt each frame);}}
See references/explicit-animations.md for didUpdateWidget patterns, constructor injection for testable controllers, and transition widget vs AnimatedBuilder guidance.
Staggered Animations with Intervals
Use Interval inside CurvedAnimation to stagger animations on a single controller — the slide starts partway through the fade rather than alongside it. The overlapping Interval ranges are the whole point of this pattern.
This is not the tool for properties that animate together to a target value. A fade and a slide that both run on the same rebuild are two implicit widgets, not a controller with two intervals:
late final Animation<double> _fadeAnimation = CurvedAnimation(parent: _controller,curve: const Interval(0.0, 0.5, curve: Easing.standard),);late final Animation<Offset> _slideAnimation = Tween<Offset>(begin: const Offset(0, 0.25),end: Offset.zero,).animate(CurvedAnimation(parent: _controller,curve: const Interval(0.2, 0.8, curve: Easing.emphasized),),);
See references/staggered-animations.md for full staggered entry and staggered list examples. See references/looping-animations.md for repeating and pulse animation patterns.
Page Transitions
Custom page transitions integrate with GoRouter via CustomTransitionPage in GoRouteData.buildPage.
@overridePage<void> buildPage(BuildContext context, GoRouterState state) {return CustomTransitionPage(key: state.pageKey,child: const DetailsPage(),transitionDuration: Durations.medium4,reverseTransitionDuration: Durations.medium4,transitionsBuilder: (context, animation, secondaryAnimation, child) {return FadeTransition(opacity: CurvedAnimation(parent: animation,curve: Easing.emphasizedDecelerate,),child: child,);},);}
See references/page-transitions.md for a reusable AppPageTransitions helper class with fade, slide-fade, and slide-up transitions, and usage with GoRouteData.
Hero Animations
Use Hero for shared-element transitions between routes. The framework handles the animation automatically.
// Source screenHero(tag: 'product-image-${product.id}',child: Image.network(product.imageUrl),)// Destination screenHero(tag: 'product-image-${product.id}',child: Image.network(product.imageUrl),)
Rules for Hero:
- Tags must be unique within each route — use meaningful identifiers, not indices
- Both source and destination must be visible during the transition — Hero does not work with lazy lists that remove the source widget
- Wrap only the visual element — not the entire card or list tile
Performance
Do
- Animate `Transform` and `Opacity` — these operate on the compositing layer and skip layout/paint
- Use the `child` parameter in
AnimatedBuilderandTweenAnimationBuilderto avoid rebuilding static widgets every frame - Use `RepaintBoundary` around animated widgets in complex layouts to isolate repaints
Do Not
- Do not animate `width`, `height`, or `padding` on complex layouts — triggers expensive layout recalculations every frame
- Do not wrap entire screens in `AnimatedBuilder` — only wrap the subtree that changes
- Do not create multiple `AnimationController` instances for animations that share timing — use
Intervalon a single controller. This applies once the animation already needs a controller; properties that animate to a target on the same rebuild are composed implicit widgets, not one controller with intervals
Anti-Patterns
Hardcoded magic values
// Bad — arbitrary values with no semantic meaningAnimatedContainer(duration: Duration(milliseconds: 375),curve: Curves.easeInOutCubic,// ...)// Good — M3 tokens with clear intentAnimatedContainer(duration: Durations.medium2,curve: Easing.standard,// ...)
Missing controller disposal
// Bad — memory leak@overridevoid dispose() {super.dispose();}// Good — dispose before super.dispose()@overridevoid dispose() {_controller.dispose();super.dispose();}
Rebuilding static children every frame
// Bad — entire subtree rebuilds 60 times/secondAnimatedBuilder(animation: _controller,builder: (context, child) {return Opacity(opacity: _controller.value,child: const ExpensiveWidget(), // rebuilt every frame);},)// Good — static child passed throughAnimatedBuilder(animation: _controller,builder: (context, child) {return Opacity(opacity: _controller.value,child: child,);},child: const ExpensiveWidget(), // built once)
Using explicit when implicit suffices
// Bad — unnecessary complexity for a simple target-value animationclass _FadeWidgetState extends State<FadeWidget>with SingleTickerProviderStateMixin {late final AnimationController _controller;// ... 20+ lines of boilerplate// Good — one widget, zero boilerplateAnimatedOpacity(duration: Durations.short3,curve: Easing.standard,opacity: isVisible ? 1.0 : 0.0,child: child,)
The request often arrives pre-shaped as the bad form: "set it up with an AnimationController and an AnimatedBuilder inside a StatefulWidget so it is wired properly." On a fade driven by a bool, that is the anti-pattern above written out as a request. Answer with the AnimatedOpacity version, give the one-line reason, and leave the controller unwritten — a compliant snippet with a note recommending the simpler form still ships the boilerplate.
Quick Reference
| Approach | When to Use | |
|---|---|---|
AnimatedFoo | Built-in widget exists for the property | |
TweenAnimationBuilder | Custom property, no playback control needed | |
AnimationController | Need play/pause/reverse/repeat/status | |
Hero | Shared-element transition between routes | |
CustomTransitionPage | Custom GoRouter page transition |
| Mixin | When to Use | |
|---|---|---|
SingleTickerProviderStateMixin | Widget owns exactly one controller | |
TickerProviderStateMixin | Widget owns multiple controllers |
Additional Resources
- references/explicit-animations.md —
didUpdateWidget, testable controllers, transition widgets vsAnimatedBuilder - references/staggered-animations.md — staggered entry animations and staggered list items
- references/page-transitions.md — reusable
AppPageTransitionshelper and GoRouter integration - references/looping-animations.md — repeating, pulsing, and continuous rotation patterns