← All posts

7 min read

Flutter Animations That Feel Native: Motion Without the Jank

A practical guide to Flutter animations that feel native — implicit and explicit animations, Hero, page transitions, curves, reduced motion and performance.

  • Flutter
  • Animations
  • UI
Cover illustration for Flutter Animations That Feel Native: Motion Without the Jank

You can usually tell a cross-platform app within a few seconds of using it. Not because of the colors or the fonts, but because of the motion. A sheet slides in a little too slowly. A card bounces when nothing should bounce. A screen transition fades on a phone where everything else slides.

Users can’t name what’s wrong. They just feel that the app is slightly off.

The good news is that Flutter gives you everything you need to get motion right, and most of it is simpler than people expect. This post is how I approach animations in production apps: what to reach for first, when to go explicit, and how to keep it all smooth.

Start with implicit animations

Most animations in a real app are “this value changed, animate to the new one”. Flutter’s implicit animation widgets handle exactly that. You change a property, rebuild, and the widget tweens from the old value to the new one on its own. No controllers, no disposal.

AnimatedContainer covers a surprising amount of ground:

AnimatedContainer(
  duration: const Duration(milliseconds: 250),
  curve: Curves.easeOutCubic,
  padding: EdgeInsets.all(selected ? 16 : 12),
  decoration: BoxDecoration(
    color: selected ? scheme.primaryContainer : scheme.surface,
    borderRadius: BorderRadius.circular(selected ? 20 : 12),
  ),
  child: Text(label),
)

There’s a whole family of these: AnimatedOpacity, AnimatedAlign, AnimatedPadding, AnimatedScale, AnimatedSlide, AnimatedDefaultTextStyle. Pick the narrowest one that does the job — AnimatedOpacity is cheaper and clearer than an AnimatedContainer that only changes opacity.

Swapping content with AnimatedSwitcher

When one widget replaces another — a loading spinner turning into content, a play icon becoming a pause icon — use AnimatedSwitcher. The one rule people trip over: the children need different keys (or different types), otherwise Flutter thinks it’s the same widget and nothing animates.

AnimatedSwitcher(
  duration: const Duration(milliseconds: 200),
  transitionBuilder: (child, animation) =>
      ScaleTransition(scale: animation, child: child),
  child: Icon(
    isPlaying ? Icons.pause : Icons.play_arrow,
    key: ValueKey(isPlaying),
  ),
)

Custom values with TweenAnimationBuilder

When there’s no Animated* widget for what you want, TweenAnimationBuilder animates any value you can describe with a Tween. Progress rings and counters are classic uses:

TweenAnimationBuilder<double>(
  tween: Tween(begin: 0, end: progress),
  duration: const Duration(milliseconds: 400),
  curve: Curves.easeOut,
  builder: (context, value, child) => CircularProgressIndicator(value: value),
)

When progress changes, it animates from the current value to the new one — not from zero — which is exactly what you want.

Go explicit when you need control

Implicit animations react to state. Explicit animations are for when you drive the timeline: looping, reversing, staggering several things, or reacting to a gesture.

That means an AnimationController, which needs a ticker. For a single controller, mix in SingleTickerProviderStateMixin:

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

  @override
  State<PulseDot> createState() => _PulseDotState();
}

class _PulseDotState extends State<PulseDot>
    with SingleTickerProviderStateMixin {
  late final AnimationController _controller = AnimationController(
    vsync: this,
    duration: const Duration(milliseconds: 900),
  )..repeat(reverse: true);

  late final Animation<double> _scale = Tween(begin: 0.85, end: 1.0).animate(
    CurvedAnimation(parent: _controller, curve: Curves.easeInOut),
  );

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

  @override
  Widget build(BuildContext context) {
    return ScaleTransition(
      scale: _scale,
      child: const DecoratedBox(
        decoration: BoxDecoration(color: Colors.red, shape: BoxShape.circle),
        child: SizedBox.square(dimension: 12),
      ),
    );
  }
}

A few habits that save debugging time:

  • Always dispose the controller. A leaked controller keeps ticking and throws “used after being disposed” errors later.
  • Use the *Transition widgets (FadeTransition, ScaleTransition, SlideTransition, RotationTransition) instead of calling setState on every tick. They only repaint what changes.
  • Use CurvedAnimation rather than baking curves into tweens. One controller can feed several curved animations with different Intervals for staggered effects.

If you need more than one controller in a widget, switch to TickerProviderStateMixin.

Hero: the cheapest “wow” in Flutter

A Hero animates a shared element between two routes — a product thumbnail in a list flying into the header of the detail screen. Wrap the widget on both screens with the same tag:

Hero(
  tag: 'product-${product.id}',
  child: Image.network(product.imageUrl, fit: BoxFit.cover),
)

Tags must be unique per screen, so build them from IDs. If the two widgets look quite different (say, a rounded thumbnail into a square banner), wrap both in a ClipRRect and let the radius differ, or use flightShuttleBuilder to control what’s drawn during the flight.

Page transitions that match the platform

This is where “doesn’t feel native” usually comes from. Android and iOS have different navigation motion, and users notice when an app gets it wrong.

MaterialApp already picks sensible defaults per platform, but I like to make the choice explicit in the theme so nobody accidentally overrides it:

ThemeData(
  pageTransitionsTheme: const PageTransitionsTheme(
    builders: {
      TargetPlatform.android: ZoomPageTransitionsBuilder(),
      TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
    },
  ),
)

On iOS, the Cupertino transition also gives you the edge swipe-back gesture. If you replace it with a custom fade on iOS, you lose that gesture — and iOS users will try to swipe back. Keep custom PageRouteBuilder transitions for special cases like modal flows, not your default navigation. If you’re using go_router, the same pageTransitionsTheme applies to its default pages; more on routing in deep linking with go_router.

Curves and durations

Most bad motion is just the wrong duration or curve. My rules of thumb, roughly following the Material motion guidance:

Kind of motion Duration Curve
Small state changes (icons, toggles, color) 100–200 ms easeOut
Elements entering the screen 200–300 ms decelerate (easeOutCubic)
Elements leaving the screen 150–250 ms accelerate (easeInCubic)
Large, full-screen transitions 300–500 ms emphasized / easeInOutCubic

Things entering should decelerate (they arrive and settle). Things leaving should accelerate (they get out of the way). Exits are usually a bit faster than entrances.

Recent Flutter versions also ship Material 3 tokens — the Durations and Easing classes — so you can use named values instead of magic numbers. And please go easy on Curves.bounceOut and Curves.elasticOut. They look fun in a demo and tiring by the tenth time.

Respect reduced motion

Some users turn on “Reduce Motion” (iOS) or “Remove animations” (Android) for accessibility reasons, including vestibular disorders where large motion can cause real discomfort. Flutter exposes this setting:

final reduceMotion = MediaQuery.disableAnimationsOf(context);

AnimatedSwitcher(
  duration: reduceMotion ? Duration.zero : const Duration(milliseconds: 250),
  child: content,
)

You don’t have to remove all feedback. Swapping a slide or zoom for a quick crossfade is often the right compromise. I put this into a small helper so every animation in the app checks it the same way. There’s more on this in accessibility in Flutter apps.

Keep animations cheap

An animation that drops frames is worse than no animation. A few things keep motion at a steady frame rate:

Animate transforms and opacity, not layout. Scaling, translating, rotating and fading are cheap. Animating width, height or padding forces layout on every frame, which gets expensive when the subtree is big. AnimatedContainer is fine for a chip; it’s a bad idea for a container wrapping an entire screen.

Don’t rebuild what doesn’t change. AnimatedBuilder (and TweenAnimationBuilder) take a child parameter. Anything passed there is built once and reused on every frame:

AnimatedBuilder(
  animation: _controller,
  child: const ExpensiveCard(), // built once
  builder: (context, child) => Transform.rotate(
    angle: _controller.value * 2 * math.pi,
    child: child,
  ),
)

Isolate repaints. A constantly animating widget — a spinner, a pulsing dot — can cause its neighbors to repaint too. Wrapping it in a RepaintBoundary gives it its own layer. Don’t sprinkle these everywhere; use DevTools’ “highlight repaints” to see where they actually help.

Watch out for opacity on big subtrees. Opacity with a value between 0 and 1 can force an offscreen buffer. FadeTransition and AnimatedOpacity handle this better, and for images you can often fade the color instead.

If you’re already seeing dropped frames, profile before guessing — I go through that process in fixing jank in Flutter apps.

When to reach for Rive or Lottie

Code-driven animation is great for UI motion. It’s the wrong tool for illustrations: an animated onboarding character, a celebratory confetti burst, a looping empty-state graphic.

  • Lottie plays animations exported from After Effects. Great if a designer already works there. Large or complex files can be heavy to render, so test on a low-end Android device.
  • Rive is built for interactive, runtime animation with state machines — a button that reacts to hover, press and success states, all designed in Rive’s editor. It’s the better fit when the animation needs to respond to app state.

My rule: if a designer needs to iterate on it, it belongs in Rive or Lottie. If it’s moving existing UI elements around, it belongs in Flutter code.

Takeaways

  • Start with implicit widgets (AnimatedContainer, AnimatedSwitcher, TweenAnimationBuilder); go explicit only when you need to drive the timeline.
  • Match platform page transitions — especially iOS swipe-back — instead of inventing your own.
  • Short durations, decelerate on enter, accelerate on exit, and skip the bounce.
  • Check MediaQuery.disableAnimationsOf and offer a calmer alternative.
  • Animate transforms and opacity, use the child parameter, and profile before adding RepaintBoundary.
  • Use Rive or Lottie for illustrative animation that designers own.

Good motion is mostly invisible — when it’s right, nobody comments on it, and the app just feels like it belongs on the phone.

Comments

Questions, corrections or your own experience — leave a comment below (GitHub sign-in).