Skip to content
Blog

Staggered Animations in Flutter: A Practical Guide to Interval-Based Motion

Learn how to build staggered animations in Flutter with a single AnimationController, Interval-based timing, and Tween objects, including a duration-derived pattern for animating lists.

Published on • October 4, 2026

AI Assistant

Staggered Animations in Flutter: A Practical Guide to Interval-Based Motion

When every element on a screen starts moving at the same instant, the result reads as noise. When elements wait for each other to finish one at a time, the sequence drags and the user loses patience. Staggered animations sit in between: each change starts at a different moment on a shared timeline, so the sequence feels deliberate while overlapping windows keep the total duration short. It is the pattern behind card entrances, drawer menus, onboarding flows, and list reveals.

Flutter’s implementation is refreshingly small. One AnimationController drives the entire sequence, and each animated property is carved out of that controller’s normalized 0.0–1.0 range using an Interval. The controller never needs to know how many things are moving or when they move — it only reports a single progress value, and every property reads its slice of it.

Key technologies: AnimationController, Interval, CurvedAnimation, Tween / EdgeInsetsTween / BorderRadiusTween / ColorTween, AnimatedBuilder, SingleTickerProviderStateMixin, TickerFuture.orCancel.

The Mental Model: One Clock, Sliced Into Intervals

A staggered animation consists of sequential or overlapping animations. To build one you need exactly four things:

  1. A single AnimationController that owns the timeline. Its value is always between 0.0 and 1.0, inclusive, regardless of how long the animation runs in real time.
  2. One Animation object per animated property, all of them driven by that controller.
  3. An Interval on each Animation, expressed as start and end percentages of the controller’s range (also 0.0–1.0, inclusive).
  4. A Tween per property, defining the begin and end values that the interval interpolates between.

The official example animates six properties off one 2000 ms controller. The intervals are worth studying because they show how gaps and overlaps are designed rather than accidental:

PropertyTweenIntervalVisual result
opacityTween<double>(0 → 1)0.000 – 0.100Fades in during the first 10%
widthTween<double>(50 → 150)0.125 – 0.250Widens, after a small gap
heightTween<double>(50 → 150)0.250 – 0.375Grows taller
paddingEdgeInsetsTween(16 → 75)0.250 – 0.375Rises upward, same window as height
borderRadiusBorderRadiusTween(4 → 75)0.375 – 0.500Square becomes a circle
colorColorTween(indigo → orange)0.500 – 0.750Recolors last
——0.750 – 1.000Nothing animates

Three details in that table are deliberate design decisions you should copy. There is a dead gap between 0.100 and 0.125 so the fade and the width change do not smear together. Two properties (height and padding) share an identical interval, which is allowed and produces a compound motion. And the final 25% of the timeline is intentionally idle, so the sequence settles before the controller reports completion.

Also note what makes the circle: a corner radius of 75.0 on a 150.0 × 150.0 box is exactly half the side length, so the rounded rectangle degenerates into a circle.

Writing the Animating Widget

The animation is always split across a widget pair. The stateless widget declares the tweens and the build() that reads their current values; the stateful widget owns the controller and triggers playback.

import 'package:flutter/material.dart';

class StaggerCard extends StatelessWidget {
  StaggerCard({super.key, required this.controller});

  final Animation<double> controller;

  late final Animation<double> _opacity =
      Tween<double>(begin: 0, end: 1).animate(
    CurvedAnimation(
      parent: controller,
      curve: const Interval(0.0, 0.10, curve: Curves.ease),
    ),
  );

  late final Animation<double> _width =
      Tween<double>(begin: 50, end: 150).animate(
    CurvedAnimation(
      parent: controller,
      curve: const Interval(0.125, 0.250, curve: Curves.ease),
    ),
  );

  late final Animation<double> _height =
      Tween<double>(begin: 50, end: 150).animate(
    CurvedAnimation(
      parent: controller,
      curve: const Interval(0.250, 0.375, curve: Curves.easeOut),
    ),
  );

  late final Animation<EdgeInsets> _padding =
      EdgeInsetsTween(
        begin: const EdgeInsets.only(bottom: 16),
        end: const EdgeInsets.only(bottom: 75),
      ).animate(
    CurvedAnimation(
      parent: controller,
      curve: const Interval(0.250, 0.375, curve: Curves.easeOut),
    ),
  );

  late final Animation<BorderRadius?> _radius =
      BorderRadiusTween(
        begin: BorderRadius.circular(4),
        end: BorderRadius.circular(75),
      ).animate(
    CurvedAnimation(
      parent: controller,
      curve: const Interval(0.375, 0.500, curve: Curves.ease),
    ),
  );

  late final Animation<Color?> _color = ColorTween(
    begin: const Color(0xFF3F51B5),
    end: const Color(0xFFFF9800),
  ).animate(
    CurvedAnimation(
      parent: controller,
      curve: const Interval(0.500, 0.750, curve: Curves.ease),
    ),
  );

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: controller,
      builder: (context, child) {
        return Padding(
          padding: _padding.value,
          child: Align(
            alignment: Alignment.bottomCenter,
            child: Opacity(
              opacity: _opacity.value,
              child: Container(
                width: _width.value,
                height: _height.value,
                alignment: Alignment.center,
                decoration: BoxDecoration(
                  color: _color.value,
                  border: Border.all(color: const Color(0xFF3F51B5), width: 3),
                  borderRadius: _radius.value,
                ),
                child: child,
              ),
            ),
          ),
        );
      },
      child: const Text(
        'Flutter',
        style: TextStyle(
          color: Colors.white,
          fontSize: 18,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}

A few things worth calling out. late final fields initialize on first read, so they can reference the controller parameter directly. Each tween sits inside a CurvedAnimation, which applies easing inside the interval rather than across the whole timeline. The static Text is passed as AnimatedBuilder’s child, so it is built once instead of on every frame. The builder reads every animation’s .value at once, so all six properties reflect the same instant.

Driving the Timeline

The stateful half creates the controller, plays it, and cleans up:

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

  @override
  State<StaggerDemoPage> createState() => _StaggerDemoPageState();
}

class _StaggerDemoPageState extends State<StaggerDemoPage>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 2000),
      vsync: this,
    );
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _play() async {
    if (_controller.isAnimating) return;
    try {
      await _controller.forward().orCancel;
      await _controller.reverse().orCancel;
    } on TickerCanceled {
      // Disposed mid-flight; nothing to clean up.
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Staggered animation')),
      body: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: _play,
        child: Center(
          child: SizedBox(
            width: 300,
            height: 300,
            child: StaggerCard(controller: _controller),
          ),
        ),
      ),
    );
  }
}

forward().orCancel returns a TickerFuture that resolves when the controller reaches 1.0 and throws TickerCanceled if the animation is interrupted — usually because the widget was disposed. Catching it keeps a disposed route from surfacing an unhandled exception. The isAnimating guard stops a second tap from restarting the sequence halfway through, which would produce a visible jump.

Duration-First Staggering for Lists

Fractions are convenient when you hand-pick them, but they become unmaintainable when the number of rows varies. The cookbook pattern inverts the workflow: declare the delays and durations in milliseconds, compute the total, then derive every Interval from those numbers.

class _MenuState extends State<Menu> with SingleTickerProviderStateMixin {
  static const _titles = [
    'Declarative style',
    'Premade widgets',
    'Stateful hot reload',
    'Native performance',
    'Great community',
  ];

  static const _initialDelay = Duration(milliseconds: 50);
  static const _itemSlide = Duration(milliseconds: 250);
  static const _stagger = Duration(milliseconds: 50);

  late final Duration _total =
      _initialDelay + (_stagger * _titles.length);

  late final AnimationController _controller;
  final List<Interval> _itemIntervals = <Interval>[];

  @override
  void initState() {
    super.initState();
    _buildIntervals();
    _controller = AnimationController(vsync: this, duration: _total)
      ..forward();
  }

  void _buildIntervals() {
    for (var i = 0; i < _titles.length; i++) {
      final start = _initialDelay + (_stagger * i);
      final end = start + _itemSlide;
      _itemIntervals.add(
        Interval(
          start.inMilliseconds / _total.inMilliseconds,
          end.inMilliseconds / _total.inMilliseconds,
        ),
      );
    }
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Widget _buildItem(int index) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        final percent = Curves.easeOut.transform(
          _itemIntervals[index].transform(_controller.value),
        );
        return Opacity(
          opacity: percent,
          child: Transform.translate(
            offset: Offset((1 - percent) * 150, 0),
            child: child,
          ),
        );
      },
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 36, vertical: 16),
        child: Text(
          _titles[index],
          style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w500),
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        for (var i = 0; i < _titles.length; i++) _buildItem(i),
      ],
    );
  }
}

The arithmetic is the whole trick: the total is _initialDelay + (_stagger * rowCount), and every interval boundary is start.inMilliseconds / total.inMilliseconds. The cookbook extends the same formula with a trailing button — add a 150 ms delay plus a 500 ms pop after the last row, recompute the total, and the derived fractions place it correctly with no manual bookkeeping.

Two curves do different jobs: Curves.easeOut gives the rows a snappy, decelerating entrance, while the button uses Curves.elasticOut, which overshoots past 1.0. Clamp anything you feed to Opacity and let the transform overshoot freely.

Choosing an Approach

ApproachClockBoilerplateTiming controlBest for
One controller + hand-picked IntervalSharedHighExact, gaps and overlapsMulti-property motion on a single widget
Duration-derived intervalsSharedMediumExact, computed from msLists and menus with N staggered rows
TweenAnimationBuilder, implicit Animated*SeparateLowSingle propertySimple entrances and toggles
Hero / route transitionsNavigatorLowRoute-drivenContinuity across pages

If two widgets must visually coordinate, they need one clock. Flutter’s tickers all read the same frame timestamp, so separate controllers do not drift frame to frame — the real risk is alignment. Unless you start them together with identical durations, the pieces fall out of step, and you lose one place to reverse or stop the sequence.

Common Pitfalls

  1. One property, two writers. Stacking Opacity and FadeTransition on the same node, or giving one property two overlapping intervals, means the last writer wins and the first is wasted work.
  2. Treating intervals as seconds. Boundaries are fractions of the controller’s normalized range, so they hold meaning as percentages, not milliseconds.
  3. Forgetting to dispose the controller. An undisposed AnimationController keeps its Ticker alive; Flutter asserts in debug builds and you leak a frame callback in release.
  4. Rebuilding the child every frame. Pass static subtrees as AnimatedBuilder.child. For opacity on a large subtree, FadeTransition avoids rebuilding the child at all.
  5. Clamping overshoot curves. elasticOut and bounceOut return values outside 0.0–1.0, and Opacity will assert on those. Clamp the opacity; let transforms overshoot.
  6. Letting sequences run too long. The documentation’s 2000 ms controller demonstrates timing slowly. Keep real sequences in the 300–700 ms range, or users read them as lag.

Wrapping Up

Staggered animation in Flutter is not a special widget or a framework feature — it is arithmetic. Give the sequence a single AnimationController, give each property a Tween and an Interval, and let AnimatedBuilder read all the values on every tick. Start by hand-picking intervals when you are designing one hero animation; switch to duration-derived intervals the moment a list length varies. Get the gaps, overlaps, and total duration right and a plain Container starts to feel choreographed.

Sources