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.

Good news: most everyday animation needs in a real app are implicit animations — a handful of lines, no controller, no ticker, nothing to dispose. Reach for explicit animations only when implicit widgets genuinely can't do what you need.

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.

WidgetAnimates
AnimatedOpacityFades a widget in or out by animating its opacity value between 0.0 and 1.0
AnimatedAlignSmoothly moves a child between two Alignment positions inside its parent
AnimatedDefaultTextStyleAnimates 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:

PieceRole
AnimationControllerDrives the animation forward over time, producing a value from 0.0 to 1.0 each frame
TweenMaps 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)
AnimatedBuilderRebuilds 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

SituationUse
A single property changes once and should animate to its new valueImplicit (AnimatedContainer, AnimatedOpacity, etc.)
An animation needs to loop forever, reverse, or run independent of a rebuildExplicit (AnimationController)
Several animated values need to stay perfectly in sync on one timelineExplicit — one controller driving multiple Tweens
A widget should visually "travel" between two screensHero
Rule of thumb: start with implicit animations. Reach for AnimationController only once you hit something implicit widgets genuinely can't express — most apps use far more implicit animation than explicit.
Next up: head to Platform Channels to see how Flutter code can reach out to native Android/iOS APIs when a Flutter package doesn't already exist for what you need.