# 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 in `TickerProviderStateMixin` (or `SingleTickerProviderStateMixin`) on your `State`.
- You must `dispose()` it.
- Its value runs 0.0 → 1.0 by default, and you map that to real values with a `Tween`.

```dart
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:

```dart
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 `CurvedAnimation`s, 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:

- **`CurvedAnimation`** applies an easing curve per animation (`easeOutCubic` for a natural settle, `easeInOutBack` for a springy overshoot).
- **`Interval`** staggers animations within the controller's timeline. `Interval(0.0, 0.5)` runs only during the first half, letting you chain without a second controller.

```dart
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 `CurvedAnimation`s 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.

```dart
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:

```dart
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:

```dart
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:

1. **`RepaintBoundary` on 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 in `RepaintBoundary` so a sliding overlay does not redraw everything beneath it.
2. **Never `setState` from a build or a listener.** Drive changes through the `AnimationController`/`ValueNotifier`, never by calling `setState` inside `build` or inside an `addListener` that triggers another rebuild — that is a guaranteed re-entrant rebuild loop.
3. **Prefer `Transform` and `Opacity` over layout-affecting properties.** Animate transform/scale/translation in the paint phase. Animating `width`, `height`, `padding`, or `Align` forces layout every frame.
4. **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.
5. **`RepaintBoundary` + `Opacity` over `AnimatedOpacity` for large subtrees.** If you are fading an entire screen, use `FadeTransition` (explicit) or wrap in `RepaintBoundary` and animate opacity directly — it can save an expensive layer composition per frame.
6. **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: `RiveAnimation` or `Lottie` files. 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.**
7. **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 `Ticker` keeps 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 the `child` caching optimization. Read them outside and pass them in.
- **Animating with `setState` on 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 inside `AnimatedBuilder`.
- **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 run` debug mode, you are tuning the wrong numbers. Profile with `--profile` (or release) on device.
- **Using `setState` where a `ValueNotifier` would do.** For a single animated scalar that does not belong to a widget's full state, a `ValueNotifier<double>` rebuilt through `ValueListenableBuilder` scopes the rebuild even tighter than `setState` — and it keeps your `State` clean.

## The Method, As a Decision Rule

When I write a custom animation now, I run through this fixed order:

1. Implicit first — does the framework have a widget that already does this?
2. If explicit, one `AnimationController` per timeline, `SingleTickerProviderStateMixin`, disposed.
3. `Tween` → `CurvedAnimation` → `Interval` for sequencing; everything mapped through `Animation<T>`.
4. Rebuild scope contained with `AnimatedBuilder`; static content in `child`.
5. Layout stays untouched; movement happens in `Transform`, fading in `Opacity`.
6. Physics (`SpringSimulation`) for gesture-driven motion; asset animations (Rive/Lottie) for authored art.
7. `RepaintBoundary` around 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.

---

*Originally published at [https://www.misar.blog/@mrgulshanyadav/articles/flutter-custom-animations-from-basic-to-production-grade](https://www.misar.blog/@mrgulshanyadav/articles/flutter-custom-animations-from-basic-to-production-grade)*

