Animation Curves: Controlling Motion Feel
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).
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 ofeaseInOut.
Physics-Inspired Curves
Curves.bounceIn/Curves.bounceOut/Curves.bounceInOut— simulates a bouncing ball.bounceOutis 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.
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),
);
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
AnimationControllerin aCurvedAnimationto 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.fastOutSlowInas the default for Material Design compliance. - Use
Cubicto define precise custom curves matching design specifications. - Supply a
reverseCurvefor asymmetric forward/reverse behaviour.