references/explicit-animations.md
# Explicit Animation Patterns
Detailed patterns for `AnimationController`-based animations. See the main skill file for core setup and standards.
## Responding to Widget Updates
Use `didUpdateWidget` to start, stop, or reverse an animation when a property changes:
```dart
class _AnimatedGlowState extends State<AnimatedGlow>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Durations.long2,
vsync: this,
);
if (widget.isGlowing) {
_controller.repeat(reverse: true);
}
}
@override
void didUpdateWidget(AnimatedGlow oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isGlowing != oldWidget.isGlowing) {
if (widget.isGlowing) {
_controller.repeat(reverse: true);
} else {
_controller.stop();
_controller.reset();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
// ...
}
```
Do not start animations in `build()`. Use `initState` for initial playback and `didUpdateWidget` for subsequent state changes.
## Constructor Injection for Testable Controllers
Expose an optional controller parameter to allow tests to drive the animation directly:
```dart
class PulsingDot extends StatefulWidget {
const PulsingDot({
required this.isActive,
super.key,
@visibleForTesting this.controller,
});
final bool isActive;
@visibleForTesting
final AnimationController? controller;
@override
State<PulsingDot> createState() => _PulsingDotState();
}
class _PulsingDotState extends State<PulsingDot>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
bool _ownsController = false;
@override
void initState() {
super.initState();
if (widget.controller != null) {
_controller = widget.controller!;
} else {
_ownsController = true;
_controller = AnimationController(
duration: Durations.long2,
vsync: this,
);
}
}
@override
void dispose() {
if (_ownsController) {
_controller.dispose();
}
super.dispose();
}
// ...
}
```
Only dispose the controller if the widget created it. Tests that inject a controller are responsible for its lifecycle.
## Transition Widgets vs AnimatedBuilder
**Single property** — use the built-in transition widget directly. Less code, same performance:
```dart
// Good — single property, use the transition widget
FadeTransition(
opacity: _fadeAnimation,
child: child,
)
```
**Multiple properties combined** — use `AnimatedBuilder` to compose them in one builder:
```dart
// Good — multiple properties, use AnimatedBuilder
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: SlideTransition(
position: _slideAnimation,
child: child,
),
);
},
child: child,
)
```
| Scenario | Use |
| --------------------------------------------------------- | ---------------------------------------------------------------------------- |
| Animate one property (opacity, position, scale, rotation) | `FadeTransition`, `SlideTransition`, `ScaleTransition`, `RotationTransition` |
| Animate multiple properties together | `AnimatedBuilder` with manual composition |
**Avoid subclassing `AnimatedWidget`** — couples the animation to a specific widget class, making reuse harder.
references/looping-animations.md
# Repeating and Looping Animations
## Continuous Rotation (Loading Indicator)
```dart
class _SpinnerState extends State<Spinner>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 1),
vsync: this,
)..repeat();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.rotate(
angle: _controller.value * 2 * pi,
child: child,
);
},
child: const Icon(Icons.refresh),
);
}
}
```
## Pulse Animation (Repeat with Reverse)
```dart
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Durations.long2,
vsync: this,
)..repeat(reverse: true);
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.05).animate(
CurvedAnimation(
parent: _controller,
curve: Easing.standard,
),
);
}
```
references/page-transitions.md
# Page Transition Patterns for GoRouter
## Shared Transition Helper
Create a reusable transition builder to maintain consistency across routes:
```dart
abstract class AppPageTransitions {
static CustomTransitionPage<void> fade({
required LocalKey key,
required Widget child,
}) {
return CustomTransitionPage(
key: key,
child: child,
transitionDuration: Durations.medium4,
reverseTransitionDuration: Durations.medium4,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: Easing.emphasizedDecelerate,
),
child: child,
);
},
);
}
static CustomTransitionPage<void> slideFade({
required LocalKey key,
required Widget child,
}) {
return CustomTransitionPage(
key: key,
child: child,
transitionDuration: Durations.medium4,
reverseTransitionDuration: Durations.medium4,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Easing.emphasizedDecelerate,
);
return FadeTransition(
opacity: curvedAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.05, 0),
end: Offset.zero,
).animate(curvedAnimation),
child: child,
),
);
},
);
}
static CustomTransitionPage<void> slideUp({
required LocalKey key,
required Widget child,
}) {
return CustomTransitionPage(
key: key,
child: child,
transitionDuration: Durations.medium4,
reverseTransitionDuration: Durations.medium4,
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final curvedAnimation = CurvedAnimation(
parent: animation,
curve: Easing.emphasizedDecelerate,
);
return FadeTransition(
opacity: curvedAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(curvedAnimation),
child: child,
),
);
},
);
}
}
```
## Using Transitions in GoRouteData
```dart
@TypedGoRoute<DetailsPageRoute>(
name: 'details',
path: 'details/:id',
)
@immutable
class DetailsPageRoute extends GoRouteData {
const DetailsPageRoute({required this.id});
final String id;
@override
Page<void> buildPage(BuildContext context, GoRouterState state) {
return AppPageTransitions.slideFade(
key: state.pageKey,
child: DetailsPage(id: id),
);
}
}
```
references/staggered-animations.md
# Staggered Animations
Stagger multiple animations on a single controller using `Interval`. Each interval defines the fraction of the controller's duration during which the animation is active.
## Enter Animation with Staggered Fade + Slide + Scale
```dart
class _StaggeredEntryState extends State<StaggeredEntry>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fadeAnimation;
late final Animation<Offset> _slideAnimation;
late final Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Durations.long2,
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.6, curve: Easing.standard),
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.15),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.1, 0.7, curve: Easing.emphasized),
),
);
_scaleAnimation = Tween<double>(begin: 0.95, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.1, 0.7, curve: Easing.emphasized),
),
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: child,
),
),
);
},
child: widget.child,
);
}
}
```
## Staggered List Items
Animate list items sequentially by offsetting each item's delay:
```dart
class StaggeredListItem extends StatefulWidget {
const StaggeredListItem({
required this.index,
required this.itemCount,
required this.animation,
required this.child,
super.key,
});
final int index;
final int itemCount;
final Animation<double> animation;
final Widget child;
@override
State<StaggeredListItem> createState() => _StaggeredListItemState();
}
class _StaggeredListItemState extends State<StaggeredListItem> {
late final CurvedAnimation _curvedAnimation;
@override
void initState() {
super.initState();
final start = (widget.index / widget.itemCount).clamp(0.0, 1.0);
final end = ((widget.index + 1) / widget.itemCount).clamp(0.0, 1.0);
_curvedAnimation = CurvedAnimation(
parent: widget.animation,
curve: Interval(start, end, curve: Easing.emphasized),
);
}
@override
void dispose() {
_curvedAnimation.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: _curvedAnimation,
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.1),
end: Offset.zero,
).animate(_curvedAnimation),
child: widget.child,
),
);
}
}
```
Usage with a parent controller:
```dart
class _StaggeredListState extends State<StaggeredList>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Durations.extralong2,
vsync: this,
)..forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final items = widget.items;
return Column(
children: [
for (var i = 0; i < items.length; i++)
StaggeredListItem(
index: i,
itemCount: items.length,
animation: _controller,
child: items[i],
),
],
);
}
}
```
SKILL.md
---
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 `AnimationController` when 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 `AnimationController` and an `AnimatedBuilder`" 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 `Duration` or `Curve` values
- **Extract animation constants** — durations, curves, and offsets go in named constants or a centralized `AppMotion` class, not inline
- **Dispose controllers** — every `AnimationController` must be disposed in the `dispose()` method of the `State`
- **Use `SingleTickerProviderStateMixin` for one controller** — use `TickerProviderStateMixin` only 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`/`height` on complex layouts causes expensive rebuilds; prefer `Transform` or `Opacity` which operate on the compositing layer
---
## Animation Decision Tree
Choose the simplest approach that meets the requirement:
```text
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.
```dart
abstract class AppMotion {
// Standard transitions
static const Duration standardDuration = Durations.medium2;
static const Curve standardCurve = Easing.standard;
// Page transitions
static const Duration pageDuration = Durations.medium4;
static const Curve pageEnterCurve = Easing.emphasizedDecelerate;
static const Curve pageExitCurve = Easing.emphasizedAccelerate;
// Fades
static 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:
```dart
class SummaryCard extends StatelessWidget {
const SummaryCard({required this.summary, super.key});
final Summary? summary;
@override
Widget 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.
```dart
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
```dart
class _MyWidgetState extends State<MyWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _fadeAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: Durations.medium2,
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _controller,
curve: Easing.standard,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget 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](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:
```dart
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](references/staggered-animations.md) for full staggered entry and staggered list examples. See [references/looping-animations.md](references/looping-animations.md) for repeating and pulse animation patterns.
---
## Page Transitions
Custom page transitions integrate with GoRouter via `CustomTransitionPage` in `GoRouteData.buildPage`.
```dart
@override
Page<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](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.
```dart
// Source screen
Hero(
tag: 'product-image-${product.id}',
child: Image.network(product.imageUrl),
)
// Destination screen
Hero(
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 `AnimatedBuilder` and `TweenAnimationBuilder` to 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 `Interval` on 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
```dart
// Bad — arbitrary values with no semantic meaning
AnimatedContainer(
duration: Duration(milliseconds: 375),
curve: Curves.easeInOutCubic,
// ...
)
// Good — M3 tokens with clear intent
AnimatedContainer(
duration: Durations.medium2,
curve: Easing.standard,
// ...
)
```
### Missing controller disposal
```dart
// Bad — memory leak
@override
void dispose() {
super.dispose();
}
// Good — dispose before super.dispose()
@override
void dispose() {
_controller.dispose();
super.dispose();
}
```
### Rebuilding static children every frame
```dart
// Bad — entire subtree rebuilds 60 times/second
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _controller.value,
child: const ExpensiveWidget(), // rebuilt every frame
);
},
)
// Good — static child passed through
AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Opacity(
opacity: _controller.value,
child: child,
);
},
child: const ExpensiveWidget(), // built once
)
```
### Using explicit when implicit suffices
```dart
// Bad — unnecessary complexity for a simple target-value animation
class _FadeWidgetState extends State<FadeWidget>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
// ... 20+ lines of boilerplate
// Good — one widget, zero boilerplate
AnimatedOpacity(
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](references/explicit-animations.md) — `didUpdateWidget`, testable controllers, transition widgets vs `AnimatedBuilder`
- [references/staggered-animations.md](references/staggered-animations.md) — staggered entry animations and staggered list items
- [references/page-transitions.md](references/page-transitions.md) — reusable `AppPageTransitions` helper and GoRouter integration
- [references/looping-animations.md](references/looping-animations.md) — repeating, pulsing, and continuous rotation patterns