Animations & Motion Design

Animation Curves: Controlling Motion Feel

16 min Lesson 4 of 13

Animation Curves: Controlling Motion Feel

In Flutter, an animation curve defines how the animated value progresses from its start to its end point over time. Without a curve, every animation advances at a constant pace — a behaviour called linear motion. Real-world objects, however, rarely move like that. A door swings open slowly, accelerates, then gently decelerates to a stop. Curves let you replicate that physical character in your UI, giving transitions a natural, polished feel.

Curves are represented by the abstract Curve class found in dart:ui and exposed through Flutter's Curves constant class. Each curve maps an input value in the range [0.0, 1.0] to an output value, also typically within [0.0, 1.0] (though overshoot curves can temporarily exceed those bounds).

Note: A curve does not change the duration of an animation — it only reshapes how the interpolated value changes within that duration. Duration and curve are orthogonal controls.

Applying a Curve with CurvedAnimation

The most common way to apply a curve is to wrap your AnimationController in a CurvedAnimation. The CurvedAnimation acts as a decorator: it forwards the same tick stream but transforms each value through the chosen curve before any Tween or AnimatedWidget reads it.

Basic CurvedAnimation Usage

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

  @override
  State<FadeInBox> createState() => _FadeInBoxState();
}

class _FadeInBoxState extends State<FadeInBox>
    with SingleTickerProviderStateMixin {
  late AnimationController _controller;
  late Animation<double> _opacity;

  @override
  void initState() {
    super.initState();
    _controller = AnimationController(
      duration: const Duration(milliseconds: 800),
      vsync: this,
    );

    // Wrap the controller in a CurvedAnimation
    final curved = CurvedAnimation(
      parent: _controller,
      curve: Curves.easeOut,       // slow start, fast middle, slow end
    );

    _opacity = Tween<double>(begin: 0.0, end: 1.0).animate(curved);
    _controller.forward();
  }

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

  @override
  Widget build(BuildContext context) {
    return FadeTransition(
      opacity: _opacity,
      child: Container(
        width: 100,
        height: 100,
        color: Colors.blue,
      ),
    );
  }
}

The Built-in Curves Library

Flutter ships dozens of ready-made curves in the Curves class. They fall into several families:

Ease Curves

  • Curves.linear — constant speed; no acceleration. Use sparingly; usually feels mechanical.
  • Curves.ease — gentle acceleration then deceleration (CSS default). A safe general-purpose choice.
  • Curves.easeIn — starts slowly, finishes fast. Good for elements leaving the screen.
  • Curves.easeOut — starts fast, finishes slowly. Good for elements entering the screen.
  • Curves.easeInOut — slow start, fast middle, slow end. Smooth and balanced.
  • Curves.easeInOutCubic — a stronger, more cinematic version of easeInOut.

Physics-Inspired Curves

  • Curves.bounceIn / Curves.bounceOut / Curves.bounceInOut — simulates a bouncing ball. bounceOut is the most natural (the object settles at the destination).
  • Curves.elasticIn / Curves.elasticOut / Curves.elasticInOut — spring-like overshoot and oscillation. Use sparingly; best for playful, game-style UIs.

Deceleration & Overshoot

  • Curves.decelerate — fast entry, gradual slowdown; often used for bottom-sheet slide-ins.
  • Curves.fastOutSlowIn — the Material Design standard curve. Strong acceleration then a long, smooth deceleration.
  • Curves.slowMiddle — fast at both ends, leisurely in the middle.
Tip: The Material Design motion guidelines recommend Curves.fastOutSlowIn for most enter animations and Curves.fastLinearToSlowEaseIn for exit animations. Following these defaults ensures your app feels cohesive with the platform.

Comparing Curves Side-by-Side

The best way to internalize what each curve looks like is to animate the same property with different curves simultaneously. The example below slides four containers to the right over the same 1-second duration, each using a different curve:

Side-by-Side Curve Comparison

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

  @override
  State<CurveComparisonPage> createState() => _CurveComparisonPageState();
}

class _CurveComparisonPageState extends State<CurveComparisonPage>
    with SingleTickerProviderStateMixin {
  late AnimationController _ctrl;

  // One controller drives all four curves
  late Animation<double> _linear;
  late Animation<double> _easeOut;
  late Animation<double> _bounceOut;
  late Animation<double> _elasticOut;

  @override
  void initState() {
    super.initState();
    _ctrl = AnimationController(
      duration: const Duration(seconds: 1),
      vsync: this,
    );

    _linear     = Tween<double>(begin: 0, end: 200)
        .animate(CurvedAnimation(parent: _ctrl, curve: Curves.linear));
    _easeOut    = Tween<double>(begin: 0, end: 200)
        .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut));
    _bounceOut  = Tween<double>(begin: 0, end: 200)
        .animate(CurvedAnimation(parent: _ctrl, curve: Curves.bounceOut));
    _elasticOut = Tween<double>(begin: 0, end: 200)
        .animate(CurvedAnimation(parent: _ctrl, curve: Curves.elasticOut));
  }

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Curve Comparison')),
      body: AnimatedBuilder(
        animation: _ctrl,
        builder: (context, _) {
          return Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              _track('linear',      _linear.value,     Colors.grey),
              _track('easeOut',     _easeOut.value,    Colors.blue),
              _track('bounceOut',   _bounceOut.value,  Colors.green),
              _track('elasticOut',  _elasticOut.value, Colors.red),
            ],
          );
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _ctrl.forward(from: 0),
        child: const Icon(Icons.play_arrow),
      ),
    );
  }

  Widget _track(String label, double offset, Color color) {
    return Row(
      children: [
        SizedBox(width: 90, child: Text(label)),
        Transform.translate(
          offset: Offset(offset, 0),
          child: Container(
            width: 30, height: 30,
            decoration: BoxDecoration(color: color, shape: BoxShape.circle),
          ),
        ),
      ],
    );
  }
}

Custom Curves with Cubic Bezier

When none of the built-in curves matches your design intent, you can create a custom one using Cubic. This mirrors the CSS cubic-bezier() function and accepts four control-point parameters: a, b, c, d.

Custom Cubic Curve

// A very snappy ease-out: fast start, sharp deceleration
const snappyEaseOut = Cubic(0.22, 1.0, 0.36, 1.0);

final animation = Tween<double>(begin: 0, end: 300).animate(
  CurvedAnimation(parent: controller, curve: snappyEaseOut),
);
Warning: Overshoot curves like elasticOut produce values briefly outside [0.0, 1.0]. If you apply such a curve to a property with strict bounds (e.g., Opacity which clamps to [0.0, 1.0]), the overshoot will be silently clipped and the spring effect will be lost. Use overshoot curves on unclamped properties such as translation offsets or scale values instead.

Reverse Curves

A CurvedAnimation accepts an optional reverseCurve parameter, which is applied when the controller plays in reverse. This is valuable for creating asymmetric enter/exit transitions that feel physically correct — for example, a drawer might slide in with easeOut but slide out with easeIn.

Asymmetric Enter / Exit Curve

final curved = CurvedAnimation(
  parent: _controller,
  curve: Curves.easeOut,        // used when playing forward
  reverseCurve: Curves.easeIn,  // used when playing in reverse
);

Summary

Animation curves are a simple but powerful lever for shaping how motion feels. Key takeaways:

  • Wrap your AnimationController in a CurvedAnimation to apply a curve.
  • Use ease-family curves for standard enter/exit transitions.
  • Use bounce and elastic curves for playful, attention-drawing effects — but use them sparingly.
  • Prefer Curves.fastOutSlowIn as the default for Material Design compliance.
  • Use Cubic to define precise custom curves matching design specifications.
  • Supply a reverseCurve for asymmetric forward/reverse behaviour.