Animations Basics
Motion is one of the things that makes a native app feel native rather than
"a website in a box" — a button that gently expands, a card that fades in, a screen that shares
an image smoothly with the one before it. Flutter's animation system covers this with two broad
approaches: implicit animations, where a widget animates a property change for
you automatically, and explicit animations, where you drive the animation yourself
with an AnimationController for full control. This lesson covers both, plus Flutter's
most delightful built-in trick: the Hero shared-element transition.
Implicit Animations: The Easy Entry Point
An implicit animation widget takes a target value for some property (a width, a color, an opacity)
and a duration. When that target value changes — usually because you called
setState() — the widget doesn't just jump to the new value, it animates smoothly toward
it on its own. You never create a controller, never call .forward(), never manage
disposal. The most commonly used implicit widget is AnimatedContainer, a drop-in
replacement for a plain Container that animates any property you change (width, height,
color, padding, border radius, and more) between rebuilds.
Example: An AnimatedContainer That Grows and Changes Color on Tap
class PulseBox extends StatefulWidget {
const PulseBox({super.key});
@override
State<PulseBox> createState() => _PulseBoxState();
}
class _PulseBoxState extends State<PulseBox> {
bool _expanded = false;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () {
setState(() {
_expanded = !_expanded;
});
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
width: _expanded ? 200 : 100,
height: 100,
decoration: BoxDecoration(
color: _expanded ? Colors.indigo : Colors.blueGrey,
borderRadius: BorderRadius.circular(_expanded ? 24 : 8),
),
),
);
}
}
Notice there is no AnimationController, no vsync, nothing to dispose.
AnimatedContainer detects that width, color, and
borderRadius changed between the old build and the new one, and animates each of them
over the given duration automatically. Tapping the box toggles _expanded,
and the widget handles the rest.
Other Common Implicit Animation Widgets
AnimatedContainer is the workhorse, but Flutter ships several other implicit widgets
that follow the exact same pattern — give it a target value and a duration, it animates the change.
| Widget | Animates |
|---|---|
AnimatedOpacity | Fades a widget in or out by animating its opacity value between 0.0 and 1.0 |
AnimatedAlign | Smoothly moves a child between two Alignment positions inside its parent |
AnimatedDefaultTextStyle | Animates changes to a TextStyle — font size, weight, or color transitioning smoothly instead of snapping |
Example: Fading a Widget In With AnimatedOpacity
AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: const Duration(milliseconds: 400),
child: Text('Now you see me'),
)
Explicit Animations: When You Need More Control
Implicit animations run once, from the old value to the new value, and stop. Sometimes you need more control than that — an animation that loops forever, reverses, runs at a custom pace, or coordinates several properties together on one shared timeline. That is what explicit animations are for, built from three pieces working together:
| Piece | Role |
|---|---|
AnimationController | Drives the animation forward over time, producing a value from 0.0 to 1.0 each frame |
Tween | Maps that 0.0–1.0 progress onto the actual range of values you want (e.g. a rotation from 0 to 2π, or a color from red to blue) |
AnimatedBuilder | Rebuilds just the widget subtree that needs to change on every animation tick, without rebuilding the whole screen |
Example: A Rotating, Fading Icon With AnimationController
class SpinningIcon extends StatefulWidget {
const SpinningIcon({super.key});
@override
State<SpinningIcon> createState() => _SpinningIconState();
}
class _SpinningIconState extends State<SpinningIcon>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _rotation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(seconds: 2),
vsync: this,
)..repeat();
_rotation = Tween<double>(begin: 0, end: 1).animate(_controller);
}
@override
void dispose() {
_controller.dispose(); // always dispose a controller you create
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _rotation,
builder: (context, child) {
return Transform.rotate(
angle: _rotation.value * 6.28, // radians, one full turn
child: child,
);
},
child: const Icon(Icons.refresh, size: 48),
);
}
}
The with SingleTickerProviderStateMixin clause is required whenever a
State class creates an AnimationController. Animations need a
"ticker" — a callback synced to the device's screen refresh rate (usually 60 times
per second) that tells the controller "advance one frame." The mixin supplies that ticker via the
vsync: this parameter, and it also ensures Flutter can pause the ticker automatically
when the screen isn't visible, so an off-screen animation doesn't waste battery. Forgetting
dispose() on the controller is a common source of memory leaks — always clean it up.
Hero Animations: Shared-Element Transitions
A Hero animation is Flutter's built-in way of making a widget appear to fly
smoothly from one screen to another during navigation — the classic example is a thumbnail image
that expands into a full detail view. Wrap the same visual widget in a Hero
on both the source and destination screens, giving both instances the exact same tag.
Flutter detects the matching tag during a page transition and automatically animates the widget's
size and position between the two layouts — no manual coordinate math required.
Example: A Hero-Wrapped Image Shared Between Two Screens
// --- Screen A: a small thumbnail in a list ---
GestureDetector(
onTap: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const DetailScreen()),
);
},
child: Hero(
tag: 'product-image-1',
child: Image.asset('assets/images/shoe.png', width: 80),
),
)
// --- Screen B: the same tag, a larger size ---
Hero(
tag: 'product-image-1', // must match exactly
child: Image.asset('assets/images/shoe.png', width: 280),
)
The tag is the only thing linking the two Hero widgets — it must be
identical on both screens, and unique per hero currently on screen (two heroes with the same tag
visible at once will throw an error). When the user taps the thumbnail, Flutter automatically
interpolates the image's size and screen position across the transition, producing a smooth,
polished "flight" effect that would otherwise take significant manual animation code to build by
hand.
Implicit vs Explicit: Which One to Reach For
| Situation | Use |
|---|---|
| A single property changes once and should animate to its new value | Implicit (AnimatedContainer, AnimatedOpacity, etc.) |
| An animation needs to loop forever, reverse, or run independent of a rebuild | Explicit (AnimationController) |
| Several animated values need to stay perfectly in sync on one timeline | Explicit — one controller driving multiple Tweens |
| A widget should visually "travel" between two screens | Hero |
AnimationController only once you hit something implicit widgets genuinely can't express — most apps use far more implicit animation than explicit.