Skip to content
FlutterLearn

Intermediate · Lesson 5 · 13 min read

Animations That Feel Right

Implicit animations, explicit controllers, Hero transitions and staggered sequences — with the performance rules that keep them smooth.

Updated July 29, 2026

What you will learn

  • Use implicit animations for the majority of cases
  • Drive explicit animations with AnimationController
  • Add shared-element transitions with Hero
  • Keep animations at 60fps and know what makes them stutter

Flutter has two animation systems. Implicit animations animate to a new value whenever you rebuild with a different one — no controller, no lifecycle. Explicit animations give you a controller you start, stop, reverse and sequence. Reach for implicit first; roughly four out of five animations need nothing more.

Implicit animations

Dart
class LikeButton extends StatefulWidget {
  const LikeButton({super.key});
  @override
  State<LikeButton> createState() => _LikeButtonState();
}

class _LikeButtonState extends State<LikeButton> {
  bool _liked = false;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _liked = !_liked),
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 250),
        curve: Curves.easeOutBack,
        padding: EdgeInsets.all(_liked ? 16 : 12),
        decoration: BoxDecoration(
          color: _liked ? Colors.pink.shade50 : Colors.grey.shade200,
          shape: BoxShape.circle,
        ),
        child: AnimatedSwitcher(
          duration: const Duration(milliseconds: 200),
          transitionBuilder: (child, animation) =>
              ScaleTransition(scale: animation, child: child),
          child: Icon(
            _liked ? Icons.favorite : Icons.favorite_border,
            key: ValueKey(_liked),   // required so the switcher sees a change
            color: _liked ? Colors.pink : Colors.grey,
          ),
        ),
      ),
    );
  }
}
WidgetAnimates
AnimatedContainerSize, colour, padding, decoration, alignment
AnimatedOpacityFade in and out
AnimatedPositionedPosition inside a Stack
AnimatedAlignAlignment within the parent
AnimatedSwitcherSwapping one child for another
AnimatedSizeA child growing or shrinking
TweenAnimationBuilderAny custom value you can tween
Dart
// Animate any value — here a counter rolling up to a new number
TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: score.toDouble()),
  duration: const Duration(milliseconds: 600),
  curve: Curves.easeOut,
  builder: (context, value, child) => Text(
    value.toStringAsFixed(0),
    style: Theme.of(context).textTheme.displayMedium,
  ),
)

Explicit animations

Use an AnimationController when you need to loop, reverse on demand, sequence several properties, or drive animation from a gesture. The controller needs a TickerProvider — that is what the SingleTickerProviderStateMixin supplies.

Dart
class PulsingDot extends StatefulWidget {
  const PulsingDot({super.key});
  @override
  State<PulsingDot> createState() => _PulsingDotState();
}

class _PulsingDotState extends State<PulsingDot>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 900),
  )..repeat(reverse: true);

  late final Animation<double> _scale = Tween<double>(begin: 0.85, end: 1.15)
      .animate(CurvedAnimation(parent: _controller, curve: Curves.easeInOut));

  @override
  void dispose() {
    _controller.dispose();   // never skip this
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scale,
      child: const CircleAvatar(radius: 8),
    );
  }
}
Dart
AnimatedBuilder(
  animation: _controller,
  // Built once, reused for all 60 frames per second
  child: const ExpensiveChart(),
  builder: (context, child) => Transform.rotate(
    angle: _controller.value * 2 * math.pi,
    child: child,
  ),
)

Staggered sequences

One controller can drive several animations at different times using Interval. The controller runs 0 → 1; each interval maps a slice of that range.

Dart
late final _controller =
    AnimationController(vsync: this, duration: const Duration(milliseconds: 900));

// Fades in over the first 40% of the timeline
late final _fade = CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.0, 0.4, curve: Curves.easeOut),
);

// Slides up during the middle 50%
late final _slide = Tween<Offset>(
  begin: const Offset(0, 0.25),
  end: Offset.zero,
).animate(CurvedAnimation(
  parent: _controller,
  curve: const Interval(0.2, 0.7, curve: Curves.easeOutCubic),
));

Hero transitions

A Hero flies a widget from one screen to the next. Give the same tag to a widget on both routes and Flutter interpolates position, size and shape for you.

Dart
// List screen
Hero(
  tag: 'product-${product.id}',
  child: Image.network(product.imageUrl, height: 80, fit: BoxFit.cover),
)

// Detail screen — same tag, different size
Hero(
  tag: 'product-${product.id}',
  child: Image.network(product.imageUrl, height: 320, fit: BoxFit.cover),
)

Keeping animations smooth

  • Always profile in release mode. Debug builds are far slower; jank you see in debug may not exist in production, and vice versa.
  • Animate transforms and opacity rather than layout properties where you can — moving a widget is cheaper than re-laying out a subtree.
  • Opacity and ClipRRect can trigger expensive offscreen buffers. FadeTransition and BorderRadius on a decoration are cheaper.
  • Never do work in a builder that runs 60 times a second — no parsing, no allocation of large objects, no DateTime.now() formatting.
  • Use RepaintBoundary around a constantly animating widget so it does not force its neighbours to repaint.
  • Respect accessibility: check MediaQuery.disableAnimationsOf(context) and skip or shorten animations when users have reduced motion enabled.
Dart
final reduceMotion = MediaQuery.disableAnimationsOf(context);

AnimatedContainer(
  duration: reduceMotion
      ? Duration.zero
      : const Duration(milliseconds: 300),
  // ...
)

Key takeaways

  • Start with implicit animations; they cover most needs with no lifecycle to manage.
  • AnimationController requires a ticker provider and must be disposed.
  • Transition widgets beat AnimatedBuilder; pass static subtrees via child either way.
  • Profile animations in release mode and honour the user's reduced-motion setting.

Practice

Animated onboarding

Build a three-page onboarding flow with a PageView. Animate a progress indicator between pages, stagger each page's title, illustration and body text with one controller, and use a Hero to fly the app logo into the home screen when onboarding completes.

Show hints
  • PageController.page gives a fractional value you can drive animations with directly.
  • Wrap the illustration in a RepaintBoundary if the progress indicator is animating alongside it.