Animations & Motion Design

Chaining Animations: Intervals and SequenceAnimation

16 min Lesson 7 of 13

Chaining Animations: Intervals and SequenceAnimation

When building polished Flutter UIs, you often need several visual properties — opacity, position, scale, color — to animate in a carefully choreographed sequence or overlap. Driving each one with its own AnimationController is wasteful and hard to synchronise. The professional solution is to use a single controller and carve its 0.0 → 1.0 timeline into named sub-ranges using Interval-bounded CurvedAnimations.

Note: Interval is a Curve subclass. When wrapped inside a CurvedAnimation, it maps only a slice of the controller's timeline to the 0.0–1.0 input of any Tween. Outside that slice the value is clamped to either 0.0 or 1.0, so the property stays still while other animations are running.

How Interval Works

Interval(begin, end, curve: ...) accepts two normalised positions on the parent controller's timeline (both between 0.0 and 1.0). Inside that window the inner curve (default: Curves.linear) is applied. Outside the window the output is clamped:

  • Before begin → output is 0.0
  • After end → output is 1.0
  • Between begin and end → the inner curve maps the local progress

Example 1 — Staggered Fade + Slide from a Single Controller

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

  @override
  State<StaggeredCard> createState() => _StaggeredCardState();
}

class _StaggeredCardState extends State<StaggeredCard>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller;

  // Sub-animation 1: fade in during the first 40 % of the timeline
  late final Animation<double> _opacity;

  // Sub-animation 2: slide up during the middle 40–90 % of the timeline
  late final Animation<Offset> _slide;

  // Sub-animation 3: scale up during the last 60–100 % of the timeline
  late final Animation<double> _scale;

  @override
  void initState() {
    super.initState();

    _controller = AnimationController(
      duration: const Duration(milliseconds: 1200),
      vsync: this,
    );

    _opacity = Tween<double>(begin: 0.0, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.0, 0.4, curve: Curves.easeIn),
      ),
    );

    _slide = Tween<Offset>(
      begin: const Offset(0.0, 0.3),
      end: Offset.zero,
    ).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
      ),
    );

    _scale = Tween<double>(begin: 0.8, end: 1.0).animate(
      CurvedAnimation(
        parent: _controller,
        curve: const Interval(0.6, 1.0, curve: Curves.elasticOut),
      ),
    );

    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _controller,
      builder: (context, child) {
        return FadeTransition(
          opacity: _opacity,
          child: SlideTransition(
            position: _slide,
            child: ScaleTransition(
              scale: _scale,
              child: child,
            ),
          ),
        );
      },
      child: const Card(
        child: Padding(
          padding: EdgeInsets.all(24.0),
          child: Text('Hello, Staggered World!'),
        ),
      ),
    );
  }
}
Tip: Notice that the child widget is passed as the child parameter to AnimatedBuilder. This subtree is built once and reused across frames, avoiding unnecessary rebuilds of static content. Only the transition wrappers re-render each frame.

Overlapping vs Sequential Intervals

Intervals can be arranged in three ways to achieve different choreography effects:

  • Sequential (no overlap): Interval(0.0, 0.5) then Interval(0.5, 1.0) — one finishes before the next begins.
  • Overlapping: Interval(0.0, 0.6) and Interval(0.4, 1.0) — they share a 20 % overlap window for a smoother crossfade effect.
  • Delayed start: Interval(0.3, 1.0) — the animation sits idle for the first 30 % of the timeline, then runs at full speed.

The SequenceAnimation Package

For complex stagger patterns the community package sequence_animation provides a fluent builder API on top of the same Interval principle. It manages the time slots for you and returns an AnimationMap keyed by string labels. While it is not part of the Flutter SDK, understanding it reinforces why the underlying Interval mechanism is so powerful.

Example 2 — Three-Step Colour Sequence (SDK-only, no package)

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

  @override
  State<ColorSequence> createState() => _ColorSequenceState();
}

class _ColorSequenceState extends State<ColorSequence>
    with SingleTickerProviderStateMixin {
  late final AnimationController _ctrl;
  late final Animation<Color?> _phase1; // blue  → green  (0–33 %)
  late final Animation<Color?> _phase2; // green → orange (33–66 %)
  late final Animation<Color?> _phase3; // orange→ red    (66–100 %)

  Color get _color =>
      _phase3.value ?? _phase2.value ?? _phase1.value ?? Colors.blue;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      duration: const Duration(seconds: 3),
      vsync: this,
    )..repeat(reverse: true);

    _phase1 = ColorTween(begin: Colors.blue, end: Colors.green).animate(
      CurvedAnimation(
        parent: _ctrl,
        curve: const Interval(0.0, 0.33, curve: Curves.linear),
      ),
    );

    _phase2 = ColorTween(begin: Colors.green, end: Colors.orange).animate(
      CurvedAnimation(
        parent: _ctrl,
        curve: const Interval(0.33, 0.66, curve: Curves.linear),
      ),
    );

    _phase3 = ColorTween(begin: Colors.orange, end: Colors.red).animate(
      CurvedAnimation(
        parent: _ctrl,
        curve: const Interval(0.66, 1.0, curve: Curves.linear),
      ),
    );
  }

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

  @override
  Widget build(BuildContext context) {
    return AnimatedBuilder(
      animation: _ctrl,
      builder: (_, __) => Container(
        width: 120,
        height: 120,
        decoration: BoxDecoration(
          color: _color,
          shape: BoxShape.circle,
        ),
      ),
    );
  }
}
Warning: ColorTween returns a nullable Color?. Always provide a fallback (as shown with the null-coalescing chain above) or use ! only when you are certain the animation has started. Forgetting this is a common source of null-dereference crashes during the first frame.

Performance Considerations

Chained interval animations are efficient because they share a single ticker. Keep these best practices in mind:

  • Use AnimatedBuilder with a static child to avoid rebuilding unchanged subtrees.
  • Prefer FadeTransition, SlideTransition, and ScaleTransition over Opacity, Transform wrappers — the transition widgets are repaint-boundary-aware.
  • Profile with the Flutter DevTools Performance tab to spot jank if intervals trigger heavy build work on every frame.

Summary

Orchestrating multiple animations from a single AnimationController is the standard Flutter pattern for staggered, choreographed motion. The key steps are: (1) create one controller with a total duration, (2) wrap it in multiple CurvedAnimations each bounded by an Interval, (3) feed each CurvedAnimation into its own Tween, and (4) render using AnimatedBuilder with the transition widgets. Intervals may be sequential, overlapping, or delayed to achieve any desired choreography without ever needing a second controller.