Chaining Animations: Intervals and SequenceAnimation
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.
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
beginandend→ 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!'),
),
),
);
}
}
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)thenInterval(0.5, 1.0)— one finishes before the next begins. - Overlapping:
Interval(0.0, 0.6)andInterval(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,
),
),
);
}
}
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
AnimatedBuilderwith a staticchildto avoid rebuilding unchanged subtrees. - Prefer
FadeTransition,SlideTransition, andScaleTransitionoverOpacity,Transformwrappers — 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.