Flutter Custom Animations: From Basic to Production-Grade
A step-by-step path from your first Tween to animations that ship to millions without dropping a frame.
The first animation I shipped to a real audience looked great in a demo and stuttered on production phones. The profile header slide-in was buttery on my test device and janky on a mid-range Android. I did what everyone does: made the animation shorter and hoped. It did not work. The frame drops were not about the duration — they were about what I was animating, how I was rebuilding, and where the work happened.
That is the difference between a Flutter animation that plays and one that is production-grade. This article walks the full path — from the basic building blocks to the techniques that keep animations at 60 fps on cheap hardware. Each step is a working piece you can extend.
Step 1: Understand the Two Families — Implicit and Explicit
Before writing any animation, you have to know which family you are in, because the tools are different:
Implicit animations are the lazy path that works for simple cases. You give a widget a target value and a duration, and the framework animates the change for you. AnimatedContainer, AnimatedOpacity, AnimatedSwitcher, TweenAnimationBuilder. No controller, no listener, no lifecycle management. This is the right choice when you want a one-off transition driven by a state change.
Explicit animations are the real machinery: AnimationController, Animation, and a Tween. You control the value, the timing, the curve, the repetition, and what rebuilds when the value changes. Any non-trivial or composed animation lives here.
Rule of thumb I use: if it changes a single property in response to a simple state change, use an implicit animation. If you need timing control, sequencing, user-dragging, physics, or repetition — go explicit from the start. Rewriting an implicit animation into an explicit one mid-project is wasted work.
A word on mental models before the code. An Animation<double> is just a value that changes over time according to its parent controller and curve. The widget tree does not animate itself; the controller drives a value, and your build method reads that value and paints a different frame. Internalize "controller drives value, value drives paint" and every example in this article — including the ones with physics and gestures — is just a variation on that loop.
Step 2: The Controller — Your Time Source
Explicit animations start with AnimationController. It is a Ticker under the hood: it asks the render tree for a new frame on every vsync, and your animation value updates each frame. Three requirements to internalize:
- It takes a
vsync, so you mix inTickerProviderStateMixin(orSingleTickerProviderStateMixin) on yourState. - You must
dispose()it. - Its value runs 0.0 → 1.0 by default, and you map that to real values with a
Tween.
class FadeSlideDemo extends StatefulWidget {
const FadeSlideDemo({super.key});
@override
State<FadeSlideDemo> createState() => _FadeSlideDemoState();
}
class _FadeSlideDemoState extends State<FadeSlideDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
@override
void initState() {
super.initState();
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
}
A controller that runs 0 to 1 does nothing by itself. You combine it with a Tween to make a meaningful Animation<T>. The animation is a listenable value — when it changes, listeners fire, and you rebuild the smallest widget that needs the new value.
Step 3: Rebuild the Right Widget — AnimatedBuilder
The single most common production mistake is rebuilding too much. If your build method listens to the controller and returns your entire screen, every animation frame rebuilds the whole subtree. On a complex screen, that is where the jank comes from.
AnimatedBuilder scopes the rebuild to exactly the widget that needs the animated value:
class FadeSlideDemo extends StatefulWidget {
const FadeSlideDemo({super.key});
@override
State<FadeSlideDemo> createState() => _FadeSlideDemoState();
}
class _FadeSlideDemoState extends State<FadeSlideDemo>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
late final Animation<Offset> _slide = Tween<Offset>(
begin: const Offset(0, 0.25),
end: Offset.zero,
).animate(CurvedAnimation(parent: _controller, curve: Curves.easeOutCubic));
@override
void initState() {
super.initState();
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _slide,
builder: (context, child) => Transform.translate(
offset: _slide.value * 200,
child: child,
),
child: Container(
width: 120,
height: 120,
color: Theme.of(context).colorScheme.primary,
),
);
}
}
Notice the child parameter: the static Container is built once and passed in, so the builder only runs the cheap Transform.translate each frame instead of rebuilding the container. That pattern — static widget in child, cheap transform in the builder — is the core of efficient Flutter animation. Offload anything expensive to child, keep only the transforming widget in the builder.
Also prefer Transform over animating layout properties like Padding or margin. Transform works in the paint phase and never triggers a re-layout; changing layout values forces layout on every frame, which is exactly how you eat frame budget.
The child parameter is a caching optimization that pays off immediately on a row of animated list items, where the builder runs for every item on every frame. If the child subtree is identical across frames — and it usually is — passing it in means the framework skips rebuilding it entirely. The builder is then just "read the animated value, apply a transform, done." That single habit has fixed more janky lists in my code than any other change, because lists are exactly where full-subtree rebuilds add up.
One controller per widget is the rule, not a guess. If a widget needs two simultaneous animations — say a logo that both fades in and scales — use a single controller and drive both with separate CurvedAnimations, or drive the scale off the same Tween. Two controllers fighting over the same state is how you get visual glitches where one animation finishes and the other is still catching up. When you genuinely need independent timelines, give each its own widget subtree rather than piling controllers into one State.
Step 4: Sequencing and Orchestration — Making It Feel Designed
A single ease-out is not a design; it is a building block. Production animations are sequences — multiple properties moving with overlapping timing. The tools:
CurvedAnimationapplies an easing curve per animation (easeOutCubicfor a natural settle,easeInOutBackfor a springy overshoot).Intervalstaggers animations within the controller's timeline.Interval(0.0, 0.5)runs only during the first half, letting you chain without a second controller.
final _in = CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 0.5, curve: Curves.easeOutCubic),
);
final _out = CurvedAnimation(
parent: _controller,
curve: const Interval(0.5, 1.0, curve: Curves.easeInCubic),
);
// Use _controller.repeat(reverse: true) for a looping pulse,
// or forward() → then reverse() for a full in/out cycle.
For genuinely parallel tracks — a hero that scales up while a backdrop fades in — you either use multiple controllers or multiple CurvedAnimations over one controller. Prefer one controller per timing timeline. The single-controller Interval approach keeps the whole sequence in one place, which is far easier to tune than four controllers fighting each other.
Step 5: Physics and Springs — When You Want It to Feel Alive
For drag-to-dismiss, pull-to-refresh, or any interaction where the user's finger drives the animation, a fixed curve is wrong. The user's velocity should matter. That is what Simulation is for: it models a physical system.
import 'package:flutter/physics.dart';
void flingRelease(DragEndDetails details, AnimationController controller) {
const spring = SpringDescription(
mass: 1,
stiffness: 180,
damping: 16,
);
final simulation = SpringSimulation(
spring,
0, // start
1, // end
details.velocity.pixelsPerSecond.dy, // initial velocity from the gesture
);
controller.animateWith(simulation);
}
The numbers are the physics: stiffness controls how aggressively it snaps home, damping controls the settle. For production, keep the settle fast — a spring that wobbles for a second feels luxurious in a demo and sluggish in a real product. Users forgive a quick snap; they notice a laggy dismissal.
Transitions Between States and Screens
So far everything has been a single widget animating in place. Production apps also need to animate between states — a loading spinner turning into content, a list item appearing, a screen entering. Flutter has purpose-built tools for each:
AnimatedSwitcher cross-fades between two children when the child changes. Give each child a distinct Key and the switcher animates the old one out and the new one in:
AnimatedSwitcher(
duration: const Duration(milliseconds: 250),
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: isLoading
? const CircularProgressIndicator(key: ValueKey('loading'))
: const ResultsView(key: ValueKey('results')),
)
Custom route transitions replace the default page slide. A PageRouteBuilder gives you a full Animation<double> to drive anything — a shared-axis transition, a scale-in, a wipe:
MaterialPageRoute(
builder: (_) => const DetailScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final t = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic);
return FadeTransition(
opacity: t,
child: ScaleTransition(scale: Tween(begin: 0.96, end: 1.0).animate(t), child: child),
);
},
)
Two production notes on transitions. First, keep cross-screen transitions short (200–350 ms) — every screen change is a moment the user is waiting. Second, respect platform conventions: iOS users expect a horizontal slide, Android users a fade-from-bottom. A beautiful custom transition that fights the platform's muscle memory is a UX bug wearing nice clothes.
Step 6: Going Production-Grade — The Checklist That Stops Jank
Everything above is craft. This step is engineering. These are the specific techniques that keep animations smooth at scale:
RepaintBoundaryon heavy animated siblings. If an animation forces a repaint, isolate it so the framework does not repaint the whole layer tree. Wrap cards, images, and complex content inRepaintBoundaryso a sliding overlay does not redraw everything beneath it.- Never
setStatefrom a build or a listener. Drive changes through theAnimationController/ValueNotifier, never by callingsetStateinsidebuildor inside anaddListenerthat triggers another rebuild — that is a guaranteed re-entrant rebuild loop. - Prefer
TransformandOpacityover layout-affecting properties. Animate transform/scale/translation in the paint phase. Animatingwidth,height,padding, orAlignforces layout every frame. - Avoid expensive work in the builder. If you need to filter a list or parse data, do it before the animation and cache the result in
child. The animation frame is not the place for computation. RepaintBoundary+OpacityoverAnimatedOpacityfor large subtrees. If you are fading an entire screen, useFadeTransition(explicit) or wrap inRepaintBoundaryand animate opacity directly — it can save an expensive layer composition per frame.- Consider Rive or Lottie for complex, authored animations. For character animation, logo intros, or anything a designer hand-authored, do not rebuild it in Flutter code. Ship the asset and play it:
RiveAnimationorLottiefiles. They render efficiently off the widget tree and keep their own timelines. The rule: code animation for the UI you own, asset animation for the art you were given. - Profile on the target hardware, not the emulator. Run the animation in release mode on the cheapest device your users actually own. The Flutter performance overlay (
debugShowPerformanceOverlay) and DevTools' timeline view tell you whether you are hitting the frame budget. If your worst device holds 60 fps there, ship it.
Pitfalls That Have Bitten Me (In Order of Damage)
- Not disposing the controller. A leaked
Tickerkeeps your widget's frame callbacks alive forever. One leaked animation on a scrollable list and the whole list stutters. MediaQuery.of(context)inside the animation builder. If your builder reads inherited widgets that can change (text scale, theme), you defeat thechildcaching optimization. Read them outside and pass them in.- Animating with
setStateon the parent. The parent rebuilds, all children rebuild, the animation is a side effect instead of a source of truth. Keep the controller's rebuild scope insideAnimatedBuilder. - Frames per second, not per animation. Three smooth animations each forcing a full-screen repaint on an old GPU is a janky screen. Budget repaint area, not just count.
- Forgetting the reduced-motion story. If the platform reports reduced motion (
MediaQuery.disableAnimations), respect it — skip the slide, keep the fade. It is an accessibility requirement, and it is also a production signal that you think about real users. - Measuring on the wrong build. Debug builds run at a fraction of release performance. If you are tuning in
flutter rundebug mode, you are tuning the wrong numbers. Profile with--profile(or release) on device. - Using
setStatewhere aValueNotifierwould do. For a single animated scalar that does not belong to a widget's full state, aValueNotifier<double>rebuilt throughValueListenableBuilderscopes the rebuild even tighter thansetState— and it keeps yourStateclean.
The Method, As a Decision Rule
When I write a custom animation now, I run through this fixed order:
- Implicit first — does the framework have a widget that already does this?
- If explicit, one
AnimationControllerper timeline,SingleTickerProviderStateMixin, disposed. Tween→CurvedAnimation→Intervalfor sequencing; everything mapped throughAnimation<T>.- Rebuild scope contained with
AnimatedBuilder; static content inchild. - Layout stays untouched; movement happens in
Transform, fading inOpacity. - Physics (
SpringSimulation) for gesture-driven motion; asset animations (Rive/Lottie) for authored art. RepaintBoundaryaround heavy siblings, reduced-motion respected, and a release-mode profile on the slowest target device before it ships.
Follow that order and your animations will feel designed instead of demoed — and they will hold 60 fps on hardware that does not flatter anyone. That is the difference between a widget that plays and a product that ships.
*Gulshan Yad
Advanced Animation Techniques
When creating complex animations, it's essential to understand how to use advanced animation techniques. One such technique is the use of a StoryBoard class to create a sequence of animations and manage their timing.
Creating a StoryBoard
To create a StoryBoard, you can use the following code:
class StoryBoard extends StatefulWidget {
@override
_StoryBoardState createState() => _StoryBoardState();
}
class _StoryBoardState extends State<StoryBoard> {
final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
final Animation<double> _animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(_controller);
@override
void initState() {
super.initState();
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Center(
child: Opacity(
opacity: _animation.value,
child: Text('Hello, World!'),
),
);
},
);
}
}
Sequencing Animations
To sequence animations, you can use the StoryBoard class to create a sequence of animations and manage their timing. The following code demonstrates how to create a sequence of animations:
class StoryBoard extends StatefulWidget {
@override
_StoryBoardState createState() => _StoryBoardState();
}
class _StoryBoardState extends State<StoryBoard> {
final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
final Animation<double> _animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(_controller);
@override
void initState() {
super.initState();
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
if (_animation.value < 0.5) {
return Center(
child: Opacity(
opacity: _animation.value,
child: Text('Hello, World!'),
),
);
} else {
return Center(
child: Opacity(
opacity: 1.0,
child: Text('Goodbye, World!'),
),
);
}
},
);
}
}
Handling Animation Errors
When creating animations, it's essential to handle any errors that may occur during animation execution. You can use a try-catch block to handle any errors that may occur:
class StoryBoard extends StatefulWidget {
@override
_StoryBoardState createState() => _StoryBoardState();
}
class _StoryBoardState extends State<StoryBoard> {
final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 2000),
);
final Animation<double> _animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(_controller);
@override
void initState() {
super.initState();
try {
_controller.forward();
} catch (e) {
print(e);
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Center(
child: Opacity(
opacity: _animation.value,
child: Text('Hello, World!'),
),
);
},
);
}
}
Best Practices for Creating Animations
When creating animations, there are several best practices to keep in mind. These include:
- Use the
AnimatedBuilderwidget to rebuild a widget when the animation changes. - Use the
TweenAnimationBuilderwidget to create a smooth transition between states. - Use the
AnimationControllerclass to manage animation state and trigger animations. - Use the
StoryBoardclass to create a sequence of animations and manage their timing. - Handle any errors that may occur during animation execution using a try-catch block.
- Consider using a state management library like Provider or BLoC to manage animation state.
- Test animations thoroughly to ensure they work as expected in different scenarios.
- Optimize animations for performance by minimizing unnecessary computations and using efficient animation techniques.
- Consider using a animation library like AutoAnimate or Animated to simplify animation creation and management.
Optimizing Animations for Performance
When creating animations, it's essential to optimize them for performance. There are several techniques you can use to optimize animations, including:
- Minimizing unnecessary computations: Avoid unnecessary computations by using efficient animation techniques and minimizing the number of computations required to animate a widget.
- Using efficient animation techniques: Use efficient animation techniques such as
TweenAnimationBuilderandAnimatedBuilderto create smooth animations. - Using a animation library: Consider using a animation library like AutoAnimate or Animated to simplify animation creation and management.
- Testing animations: Test animations thoroughly to ensure they work as expected in different scenarios.
- Optimizing animation timing: Optimize animation timing by using techniques such as easing functions and animation curve to create smooth animations.
- Reducing animation overhead: Reduce animation overhead by minimizing the number of animations used and using efficient animation techniques.
- Using a state management library: Consider using a state management library like Provider or BLoC to manage animation state and reduce animation overhead.
Animation Best Practices for Different Devices
When creating animations, it's essential to consider the device on which the animation will be displayed. Different devices have different capabilities and limitations, and animations should be optimized for each device. Here are some animation best practices for different devices:
- Mobile devices: Use efficient animation techniques such as
TweenAnimationBuilderandAnimatedBuilderto create smooth animations on mobile devices. - Desktop devices: Use more complex animation techniques such as
StoryBoardandAnimationControllerto create more complex animations on desktop devices. - Tablets: Use a combination of efficient and complex animation techniques to create animations that are suitable for tablets.
- Wearables: Use simple animation techniques such as
TweenAnimationBuilderandAnimatedBuilderto create simple animations on wearables. - TVs: Use complex animation techniques such as
StoryBoardandAnimationControllerto create complex animations on TVs.
Conclusion
In conclusion, creating custom animations in Flutter can be a complex task, but with the right techniques and best practices, you can create smooth and engaging animations that enhance the user experience. By following the techniques and best practices outlined in this article, you can create animations that are optimized for different devices and scenarios, and that provide a seamless and engaging user experience.
Key Takeaways
- Implement custom animations by extending the
AnimatedBuilderwidget and usingTweenAnimationBuilderto create a smooth transition between states. - Use the
AnimationControllerclass to manage animation state and trigger animations using theanimateTomethod. - Custom animations can be achieved by creating a custom
Animationclass, allowing for complex, custom animation behaviors. - For more complex animations, utilize the
StoryBoardclass to create a sequence of animations and manage their timing. - To implement a production-grade animation system, consider using a state management library like Provider or BLoC to manage animation state.
Frequently Asked Questions
How do I create a custom animation in Flutter?
Create a custom animation by extending the AnimatedBuilder widget and using TweenAnimationBuilder to create a smooth transition between states.
How do I manage animation state in Flutter?
Use the AnimationController class to manage animation state and trigger animations using the animateTo method.
Can I create complex animations in Flutter?
Yes, create a custom Animation class to achieve complex, custom animation behaviors.
How do I sequence animations in Flutter?
Utilize the StoryBoard class to create a sequence of animations and manage their timing.
What is the best way to manage animation state in a production-grade application?
Consider using a state management library like Provider or BLoC to manage animation state.
How do I handle animation errors in Flutter?
Use a try-catch block to handle any errors that may occur during animation execution.
Can I animate non-widget properties in Flutter?
No, animations in Flutter are limited to widget properties and cannot be applied to non-widget properties.
1 followers
AI systems builder · 7 years in production. RAG, self-hosted infra, agent architecture. 📬 Deep-dives → mrgulshanyadav.substack.com




Comments
Sign in to join the conversation
No comments yet. Be the first to share your thoughts!