Skip to content
ExponaBLOG
FLUTTER · DAY 053 / 100

Rich animations with Lottie and Rive: a practical guide

Designer-made animations in your app.

Expona Admin8 min read

Rich animations with Lottie and Rive

Most Flutter apps feel lifeless without thoughtful animation. Hand-coding every transition and micro-interaction burns developer time and rarely matches designer intent. Rich animations with Lottie and Rive let your designers create motion in tools they know, then export formats you drop straight into your codebase—no mathematical tweening required.

Why animations matter in production apps

I've shipped e-commerce, travel, and SaaS platforms where animation wasn't cosmetic—it was feedback. Empty states that just show a sad icon feel abandoned. Loading screens with actual motion feel responsive. Onboarding flows that move smoothly guide users through your product narrative.

But here's the catch: building these animations pixel-perfect in code is expensive. You either hire animators comfortable with Dart (rare), or designers hand over static frames and developers guess the timing. Lottie and Rive solve this by letting designers work in their native tools, then you consume those animations as packages in Flutter.

Lottie plays After Effects exports

Lottie is the simpler of the two. Your designer creates an animation in Adobe After Effects, Figma, or similar tools, exports it as a JSON file, and you render it in Flutter using the lottie package.

Why use Lottie: It's straightforward, supports complex frame-by-frame animations, and works beautifully for one-off animations like success screens, error states, or loading loops.

Setting it up:

dart
flutter pub add lottie

Then in pubspec.yaml, ensure you have:

dart
dependencies:
  flutter:
    sdk: flutter
  lottie: ^2.7.0

Real-world example: Payment success animation

I built a ride-hailing app where the payment confirmation needed celebratory motion. The designer created a check-mark animation with confetti in After Effects, exported it as JSON, and I embedded it like this:

dart
import 'package:lottie/lottie.dart';

class PaymentSuccessScreen extends StatefulWidget {
  @override
  State createState() => _PaymentSuccessScreenState();
}

class _PaymentSuccessScreenState extends State
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 2800),
      vsync: this,
    );
    // Play animation once, then navigate
    _controller.forward().then((_) {
      Future.delayed(const Duration(milliseconds: 800), () {
        if (mounted) Navigator.pop(context);
      });
    });
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Lottie.asset(
          'assets/animations/payment_success.json',
          controller: _controller,
          onLoaded: (composition) {
            // Animation loaded; duration is set
            debugPrint('Animation duration: ${composition.duration}');
          },
        ),
      ),
    );
  }
}

Key points: I control playback with an AnimationController, so I can decide when to advance or loop. The onLoaded callback lets me read the animation's actual duration and plan what happens next (navigation in this case).

Loading remote animations: In a CRM I built, we stored animations on S3 and loaded them dynamically:

dart
Lottie.network(
  'https://s3.amazonaws.com/animations/loading_spinner.json',
  width: 120,
  height: 120,
  repeat: true,
)

This is useful when you want to A/B test animations or update them without a new app release.

Rive supports interactive state machines

Rive takes animation further. Instead of static JSON files, Rive lets you define state machines—animations respond to user input or app state in real-time. A button can smoothly transition between idle, hover, and pressed states. A navigation menu can open and close with chained animations.

Why use Rive: Complex interactions, game-like UIs, or anything that needs to react instantly to user input without frame-by-frame coding.

Setting it up:

dart
flutter pub add rive

Real-world example: Interactive onboarding with state-driven animations

I worked on a trading app where onboarding needed to guide users through account setup with branching paths. Rive's state machines were perfect—the designer created a character that reacted to whether the user entered valid data or not.

dart
import 'package:rive/rive.dart';

class OnboardingCharacter extends StatefulWidget {
  final bool isValid;
  final bool isLoading;

  const OnboardingCharacter({
    required this.isValid,
    required this.isLoading,
  });

  @override
  State createState() => _OnboardingCharacterState();
}

class _OnboardingCharacterState extends State {
  late RiveAnimationController _controller;
  Artboard? _riveArtboard;

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

  void _loadRiveFile() async {
    final data = await rootBundle.load('assets/animations/onboarding_character.riv');
    final file = RiveFile.import(data);
    final artboard = file.mainArtboard;
    
    // Get the state machine controller
    _controller = StateMachineController.fromArtboard(
      artboard,
      'OnboardingStateMachine',
    ) as StateMachineController;
    
    artboard.addController(_controller);
    setState(() => _riveArtboard = artboard);
  }

  @override
  void didUpdateWidget(OnboardingCharacter oldWidget) {
    super.didUpdateWidget(oldWidget);
    
    // Trigger state machine inputs based on validation
    if (widget.isValid && !oldWidget.isValid) {
      _controller.findInput('isValid')?.value = true;
    }
    if (widget.isLoading && !oldWidget.isLoading) {
      _controller.findInput('isLoading')?.value = true;
    }
  }

  @override
  Widget build(BuildContext context) {
    return _riveArtboard == null
        ? const SizedBox(width: 200, height: 200)
        : Rive(artboard: _riveArtboard!);
  }

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

Here, the state machine in Rive listens to boolean inputs (isValid, isLoading). When you set findInput('isValid')?.value = true, the animation plays the "happy" state. If false, it plays the "confused" state. No manual animation code needed—the designer defined all transitions in Rive's editor.

Keep files small

Animation files can bloat your app size quickly. A 5 MB Lottie JSON or a 2 MB Rive file per screen adds up fast, especially in emerging markets where app size matters.

Optimization strategies:

  • Limit frame count: Ask designers to keep animations under 2–3 seconds. Longer loops with fewer frames feel smoother than long, complex ones.
  • Use vector shapes: Both Lottie and Rive are vector-based, but remind designers to avoid rasterizing images inside animations. Vectors scale and stay small.
  • Compress and minify: Lottie's LottieComposition object is already efficient, but you can minify JSON. Rive's binary format is already compact.
  • Load selectively: Cache animations after the first load. Don't load all animations at startup.
  • Lazy load: Use Lottie.network or defer loading until the screen appears.

Example: Preload animations on demand

dart
class AnimationPreloader {
  static final Map _cache = {};

  static Future preload(String assetPath) async {
    if (_cache.containsKey(assetPath)) {
      return _cache[assetPath]!;
    }
    final composition = await Lottie.loadAnimation(
      AssetSource(assetPath),
    );
    _cache[assetPath] = composition;
    return composition;
  }
}

Call this once when your app starts or when entering a section that uses animations, not on every frame.

Use for onboarding and empty states

Two places where animations shine: onboarding flows and empty states.

Onboarding: Animations guide users through your app's features. In the trading app I mentioned, each step had a small animation showing how to place an order. Users didn't skip onboarding—they watched because the motion made it feel personal.

Empty states: A blank screen with "No transactions" is sad. The same screen with an animated piggy bank filling up? Now it's aspirational. I've seen this increase feature adoption by making the app feel alive, not broken.

Example: Empty state with Lottie

dart
class EmptyTransactionState extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Lottie.asset(
            'assets/animations/empty_wallet.json',
            width: 200,
            height: 200,
            repeat: true,
          ),
          const SizedBox(height: 16),
          Text(
            'No transactions yet',
            style: Theme.of(context).textTheme.titleLarge,
          ),
          const SizedBox(height: 8),
          Text(
            'Make your first transfer to get started',
            style: Theme.of(context).textTheme.bodyMedium,
          ),
          const SizedBox(height: 24),
          ElevatedButton(
            onPressed: () => Navigator.pushNamed(context, '/send_money'),
            child: const Text('Send Money'),
          ),
        ],
      ),
    );
  }
}

The animation loops indefinitely, drawing attention without being distracting. The call-to-action button sits below, clear and prominent.

Common mistakes

  • Forgetting to dispose AnimationControllers: This leaks resources. Always call _controller.dispose() in the dispose method of your State class.
  • Loading large animations synchronously: If you block the main thread loading a 3 MB JSON, your UI jank. Use Lottie.network or load asynchronously with FutureBuilder.
  • Mismatched Rive input types: If your state machine expects a double but you send a bool, it silently fails. Always verify input names and types in the Rive editor.
  • Playing animations too frequently: Looping a complex animation constantly drains battery. Only loop when the user is actively viewing it.
  • Ignoring platform differences: Test animations on actual devices, not just the emulator. Android and iOS may render slightly differently due to rasterization.

Key takeaways

  • Lottie is your go-to for static, frame-based animations exported from After Effects. It's simple, reliable, and perfectly suited for one-off effects like success screens and loading states.
  • Rive powers interactive animations driven by state machines. Use it when your animation needs to respond to user input or app state in real-time.
  • Keep animation files small by limiting frame counts, using vectors only, and lazy-loading. Monitor your app size during development.
  • Deploy animations in onboarding flows and empty states to make your app feel polished and guide users through key interactions.
  • Always dispose AnimationControllers and load large files asynchronously to avoid jank and resource leaks.
  • Test on real devices to catch rendering quirks before release.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles