Skip to content
Blog

flutter_animate: Beautiful Flutter Animations Without an AnimationController

A practical guide to flutter_animate 4.5.2 — chained effects, timing inheritance, staggered lists, state-driven targets, and scroll adapters without manual AnimationController boilerplate.

Published on • October 4, 2026

AI Assistant

flutter_animate: Beautiful Flutter Animations Without an AnimationController

A single fade-in in idiomatic Flutter costs you a StatefulWidget, an AnimationController wired to SingleTickerProviderStateMixin, a Tween, an AnimatedBuilder, and a dispose() that you must not forget. That is fine for one hero animation. It is miserable for a dashboard where forty cards each need an entrance, a hover state, a shimmer while loading, and a stagger offset relative to the card above it. The plumbing grows faster than the UI.

flutter_animate collapses that into a chained API: wrap a widget, list the effects you want, and let the package own the controller. Version 4.5.2 is published by gskinner.com under a BSD-3-Clause license, reports 150 pub points with roughly 1.05M downloads and 4.2k likes on pub.dev, and runs on Android, iOS, Linux, macOS, web, and Windows. It is also a Flutter Favorite — one of seven packages announced in the January 22, 2024 blog post Progress of the Flutter Package Ecosystem, alongside flame, riverpod, video_player, macos_ui, fpdart, and flutter_rust_bridge.

Key technologies: flutter_animate 4.5.2, Animate, the Widget.animate() extension, FadeEffect / ScaleEffect / MoveEffect, ThenEffect, AnimateList(interval:), ScrollAdapter, target and onPlay, flutter_shaders ^0.1.2.

Installation and the Two Syntaxes

dependencies:
  flutter:
    sdk: flutter
  flutter_animate: ^4.5.2

The package depends only on flutter and flutter_shaders (for ShaderEffect). After flutter pub get you get two equivalent ways to write the same thing:

import 'package:flutter_animate/flutter_animate.dart';

// Declarative: name the effects explicitly.
Animate(
  effects: [FadeEffect(), ScaleEffect()],
  child: const Text('Hello World'),
)

// Chained: the .animate() extension wraps the widget in Animate().
Text('Hello World').animate().fade().scale()

Effects run in parallel by default. The chained form is what you will use most, because each effect class also exposes a chainable extension method with the same parameters as its constructor.

Timing: Delay, Duration, Curve, and Inheritance

Every effect takes optional delay, duration, and curve parameters. When you omit one, it is inherited from the previous effect in the chain; if there is no previous effect, duration and curve fall back to Animate.defaultDuration and Animate.defaultCurve, and delay falls back to Duration.zero. Duration literals come from extensions on num, so 300.ms, 2.seconds, and 0.1.minutes all compile to Duration.

Card(
  child: ListTile(
    leading: const Icon(Icons.rocket_launch_outlined),
    title: const Text('Deployment finished'),
    subtitle: const Text('2 minutes ago'),
  ),
)
    .animate()
    .fadeIn(duration: 400.ms, curve: Curves.easeOut)
    .move(begin: const Offset(0, 24), curve: Curves.easeOut)
    .scaleXY(begin: 0.96, curve: Curves.easeOut);

Here the fade sets duration: 400.ms; the move and the scale inherit it. All three run concurrently, so the card fades, rises, and grows as one gesture.

Most effects also take begin and end values with a smart default: specify only one and the other falls back to a neutral value that produces no visual change. .fade(end: 0.8) therefore means “start at full opacity, settle at 80%”, while .fade() alone means 0 to 1. scaleXY takes plain doubles, while scale takes an Offset so you can scale axes independently.

Two different delay semantics are worth memorizing:

DelayLives onRepeats when looped?
Animate(delay: ...)the widgetNo — applied once, before the first play
Effect(delay: ...)an individual effectYes — reapplied at the start of every loop
Text('Hello')
    .animate(
      delay: 1.seconds,
      onPlay: (controller) => controller.repeat(),
    )
    .fadeIn(delay: 500.ms)
    .scaleXY(begin: 0.9, delay: 500.ms);

To run effects strictly one after another rather than in parallel, use ThenEffect, exposed as .then(). It rebases the timeline to the previous effect’s end time, and all later delays are measured from there:

Text('Hello').animate()
    .fadeIn(duration: 600.ms)
    .then(delay: 200.ms)
    .slide(begin: const Offset(0, 0.2));

The slide starts 800 ms in, two hundred milliseconds after the fade completes.

Staggering Lists

For a column of rows, AnimateList wraps every child in its own Animate and proxies effect calls to all of them, offsetting each child’s start time by interval. This is the package’s answer to the Interval arithmetic you would otherwise hand-compute:

Column(
  crossAxisAlignment: CrossAxisAlignment.start,
  children: <Widget>[
    for (final title in _titles)
      Padding(
        padding: const EdgeInsets.symmetric(vertical: 8),
        child: Text(title, style: Theme.of(context).textTheme.titleLarge),
      ),
  ]
      .animate(interval: 120.ms)
      .fadeIn(duration: 300.ms, curve: Curves.easeOut)
      .slide(begin: const Offset(0.15, 0), curve: Curves.easeOut),
)

The declarative equivalent is AnimateList(children: [...], effects: [...], interval: 120.ms). Note that AnimateList.ignoreTypes skips certain widgets by default — Spacer is in that set, so layout spacers do not consume a stagger slot. AnimateList.defaultInterval lets you change the default globally.

What You Stop Writing

TaskManual Flutterflutter_animate
Fade + rise + scale a card inStateful widget, controller, 3 tweens, AnimatedBuilder, dispose().animate().fadeIn().move().scaleXY()
Stagger 5 list rowsSum durations, divide each boundary by the total to get Interval fractions.animate(interval: 120.ms).fadeIn()
Sequence two effectsChain forward() futures or compute non-overlapping intervals.then(delay: 200.ms).slide()
Hover / pressed stateImplicit AnimatedContainer or manual target math.animate(target: _over ? 1 : 0)
Scroll-linked revealAnimatedBuilder reading scrollController.offset with your own normalization.animate(adapter: ScrollAdapter(_scrollController))
One-off custom propertySubclass Tween, override lerp, write the builder.custom(builder: (context, value, child) => ...)

State-Driven Animation with target

Animate can behave like the implicit Animated* widgets. Set target to a value between 0 and 1, and whenever it changes the internal controller animates to the new position. value does the same job but jumps instantly.

class HoverTile extends StatefulWidget {
  const HoverTile({super.key});

  @override
  State<HoverTile> createState() => _HoverTileState();
}

class _HoverTileState extends State<HoverTile> {
  bool _over = false;

  @override
  Widget build(BuildContext context) {
    return MouseRegion(
      onEnter: (_) => setState(() => _over = true),
      onExit: (_) => setState(() => _over = false),
      child: ColoredBox(
        color: Theme.of(context).colorScheme.surfaceContainerHighest,
        child: const Padding(
          padding: EdgeInsets.all(24),
          child: Text('Hover to inspect'),
        ),
      )
          .animate(target: _over ? 1 : 0)
          .fade(end: 0.8)
          .scaleXY(end: 1.1),
    );
  }
}

Because only end is specified, both effects default their begin to the neutral value (opacity 1, scale 1) — at rest the tile looks untouched, and hovering eases it to 80% opacity and 110% scale.

Callbacks, Loops, and Adapters

Animate exposes three callbacks that hand you the internal AnimationController: onInit (controller created), onPlay (started, after the widget-level delay), and onComplete (all effects finished). They are the escape hatch for looping and reversing:

Text('Horrible pulsing text')
    .animate(onPlay: (controller) => controller.repeat(reverse: true))
    .fadeOut(duration: 900.ms, curve: Curves.easeInOut);

For a fixed number of repeats, the package adds a loop() extension on AnimationController that behaves like repeat() but accepts a count. Inside a chain, CallbackEffect fires at an arbitrary point and ListenEffect streams the current double value:

Text('Hello').animate()
    .fadeIn(duration: 600.ms)
    .callback(duration: 300.ms, callback: (_) => debugPrint('halfway'))
    .listen(callback: (value) => debugPrint('opacity: $value'));

Adapters replace the time-based controller with an external source. ScrollAdapter maps a scroll range onto the 0–1 animation range, ValueAdapter and ValueNotifierAdapter read a plain value, and ChangeNotifierAdapter reacts to a notifier. begin and end trim the pixel range, and negative end values are measured from the end of the scroll extent:

Text('Release notes').animate(
  adapter: ScrollAdapter(
    _scrollController,
    begin: 100,
    end: -200,
  ),
)
    .fadeIn()
    .slide(begin: const Offset(0, 0.1));

That runs once the list scrolls past 100 px and finishes 200 px before the bottom. Setting autoPlay: false stops the animation from calling forward() automatically, which is what you want when an external controller or adapter drives it.

Custom Effects and Shaders

When no built-in effect fits, CustomEffect gives you a builder with a 0–1 value:

Text('Hello World').animate().custom(
  duration: 300.ms,
  builder: (context, value, child) => Container(
    color: Color.lerp(Colors.red, Colors.blue, value),
    padding: const EdgeInsets.all(8),
    child: child,
  ),
);

ToggleEffect flips a boolean at a point in time (useful for switching AnimatedContainer properties with a tiny delay), and SwapEffect replaces the whole target widget — the documented fix for the composition trap where myWidget.animate().fadeOut(200.ms).fadeIn(delay: 200.ms) never appears, because both effects keep applying opacity simultaneously. ShaderEffect applies animated GLSL fragment shaders, backed by the flutter_shaders dependency.

Performance Notes

  1. Effects are composed, not sequential. Two effects on the same property both keep applying, so the earlier one can cancel the later one out. Use then() to order them, or swap() to reset the chain first.
  2. One Animate = one internal AnimationController. A list of fifty independent entries is fifty tickers. Prefer AnimateList, which shares timing logic across children, and profile before animating very long lists.
  3. Effects stay “active” for the full animation window. An effect with an early delay still occupies the timeline, which is why inherited delays make effects run together rather than one after another.
  4. Set Animate.restartOnHotReload = true while iterating. Animations replay on every hot reload instead of sitting at their end state, which makes timing tweaks much faster to judge.
  5. Reuse immutable effects. Effect instances are const-friendly and shareable, so a List<Effect> for your design system’s transition-in can live in one place and be updated globally without rebuilding effect objects per frame.
  6. Do not animate what the scroll already moves. With a ScrollAdapter, keep effects to opacity and subtle transforms; large repaints during scroll are where frame budgets break.

Wrapping Up

flutter_animate does not give you new animation primitives — AnimationController, tweens, and curves are all still there under the hood. What it removes is the ceremony: the stateful wrapper, the mixin, the disposal, and the manual interval arithmetic that turns a five-line visual idea into a hundred lines of scaffolding. Reach for it on lists, cards, hover states, and scroll-driven reveals. Reach for a hand-built controller when you need precise multi-property choreography or tight integration with a navigator — and, when you do, the onPlay and controller hooks keep the escape hatch open.

Sources