Motion design is a communication channel, not decoration. Learn the three questions every UI animation must answer, easing curves vs spring physics, and Flutter motion patterns.
A designer once handed me a build with the note: "Added some nice animations, feels premium now." I opened it and watched a button scale up and bounce on tap, a list fade in over 400ms every single time I scrolled back to it, and a modal slide up while the background did a little parallax drift. Everything moved. Nothing meant anything. It felt like a slide deck built by someone who had just discovered the transition menu.
Six years of shipping Flutter apps has convinced me of one thing about this: motion is a language, and most apps are mumbling. They animate because animation is available, not because they have something to say. Good motion design isn't garnish you spoon on at the end to make a screenshot look expensive. It's a channel, exactly like color and typography are channels, and every animation on screen is either a sentence that answers a question the user is silently asking or it's noise you should have deleted before review.
I'm going to make an argument that will sound backwards for a post about animation: the goal of good motion work is usually to ship less of it. The skill isn't adding movement. It's knowing which three questions movement is allowed to answer, and cutting everything that answers none of them. That single discipline is what separates an interface that feels crafted from one that just feels busy — and, as you'll see, it maps directly onto how you architect your animation code, not only how it looks.
When a user taps, drags, waits, or navigates, their brain is quietly asking one of a small set of questions. Motion exists to answer them, fast, before the user has to think. If a given animation doesn't answer one of these, it's decoration, and decoration in a UI is a cost with no return: it burns frames, adds latency, and dilutes the motion that actually means something.
The three questions:
That's the whole list. Causality, state, spatial continuity. Before I add any animation now, I write down which of the three it serves. If I can't name one in a plain sentence, the animation doesn't ship. That single rule has deleted more motion from my apps than any performance budget ever has.
The textbook offender is the gratuitous fade. A screen's content fades in every time it appears. It answers nothing. The user already knows they navigated here because they tapped the thing that brought them. The fade just adds latency to a transition nobody needed explained, and it does it forever, on every visit. Delete it. You will not get a bug report.
Say you have a "favorite" heart on a product card. Ask the three questions in order. Causality: yes — tapping it needs a response, so a quick scale-and-fill answers "your tap registered." State: yes — the icon going from outline to filled is the new state, and a color transition sells it. Spatial continuity: no — nothing moved to a new place, so no slide, no hero, nothing. Two questions answered, one correctly ignored. The result is a 150ms micro-interaction, not a production number. Most motion decisions really are this mechanical once you have the checklist.
If motion is a language, easing is its grammar. The curve you pick isn't a cosmetic knob. It changes the meaning of the sentence.
A linear curve says "a machine moved this." Nothing in the physical world starts and stops at a constant velocity, so linear motion reads as mechanical and faintly wrong — the uncanny valley of animation. Use it for progress bars and loading fills where you actually want to signal a steady mechanical process, and almost nowhere else.
Ease-out — fast start into a slow settle — is the workhorse. It says "this arrived and is coming to rest," which is what you want for anything entering the screen: it should decelerate into place the way a real object with momentum does. Ease-in — slow start into a fast exit — says "this is leaving," and belongs on elements heading off-screen. Ease-in-out is for elements moving within the screen from one resting place to another. Mix these up and everything feels subtly off even when nobody can tell you why. In Flutter these map cleanly onto Curves.easeOut, Curves.easeIn, and Curves.easeInOut, with the cubic variants (Curves.easeOutCubic) giving you a more pronounced, more expensive-feeling deceleration.
Then there's spring physics, which says something no curve can: "this object has mass and I'm responding to your gesture in real time." A spring isn't defined by a duration. It's defined by stiffness and damping. That distinction is the whole game, because a duration-based curve cannot respond to how the user flung something. A spring can.
// A duration curve fakes physics. It always takes 300ms,// no matter how hard the user threw the sheet.AnimatedContainer( duration: const Duration(milliseconds: 300), curve: Curves.easeOutCubic, // ...);// A spring is physics. Its settle time depends on the// velocity you hand it, so a hard fling feels snappy// and a gentle nudge feels gentle.final spring = SpringDescription( mass: 1, stiffness: 500, damping: 30,);final sim = SpringSimulation(spring, 0, 1, gestureVelocity);controller.animateWith(sim);
The tell of an app that understands this: fling a bottom sheet upward and let go, and it keeps the energy of your throw. The tell of an app that doesn't: fling it as hard as you like and it plays the same polite 300ms animation every time, so the object feels weightless and disconnected from your hand.
Damping is the tone of the sentence. Under-damp and it bounces, which reads as playful and, past a point, as clownish. Critically damp and it settles clean, which reads as confident and calm. For most product UI I want damping that produces either zero overshoot or a single tiny one. A checkout button that jiggles three times before it settles is a button that doesn't trust itself, and I don't want my payment flow doing improv. A useful mental model: raise stiffness to make the spring reach its target sooner, raise damping to kill the bounce. Tune the two together against the actual gesture, not against a stopwatch.
Junior motion work animates one widget at a time. Mature motion work choreographs a whole screen. The difference is orchestration: deciding the order, overlap, and stagger of many elements so the eye is led rather than assaulted.
When a screen appears with twelve elements and all twelve animate at once, the user's eye has no anchor. It's twelve things shouting the same word in unison. Stagger them by a small delay — 20 to 40ms between siblings — and a reading order appears out of nowhere. The eye follows the cascade top to bottom, which happens to be exactly the order you wanted it to scan the content anyway. You didn't just decorate the entrance. You directed it.
// Stagger a list so items cascade instead of popping in// all at once. Index-based delay is the whole trick.Widget buildItem(int index) { final start = (index * 0.05).clamp(0.0, 0.5); final anim = CurvedAnimation( parent: controller, curve: Interval(start, (start + 0.4).clamp(0.0, 1.0), curve: Curves.easeOut), ); return FadeTransition( opacity: anim, child: SlideTransition( position: Tween(begin: const Offset(0, 0.1), end: Offset.zero) .animate(anim), child: row, ), );}Two rules keep staggering from becoming its own species of noise:
.clamp(0.0, 0.5) above — that ceiling is exactly this rule expressed in code.Orchestration is also hierarchy. The primary element should lead, and secondary elements should follow and support it. If your empty-state illustration and your "add" button animate with identical weight and timing, you've told the user they matter equally. They don't, and now the screen is arguing with itself. Give the primary action a slightly earlier start and a slightly larger move, and let everything else defer to it.
The single highest-value animation you can build is a shared-element transition: the photo thumbnail in a grid that expands smoothly into the full-screen photo on the detail page, then collapses back into the exact same grid cell when you dismiss.
It's high value because it answers the spatial-continuity question perfectly. The user never loses the object. The detail screen isn't a new place that materialized from nowhere; it is this specific thumbnail, up close. And because the object animates back to its origin on the way out, the user always knows where "back" lives. They never have to rebuild the grid in their head, because the app hands them the position for free.
// Grid cell.Hero(tag: 'photo-${photo.id}', child: Thumbnail(photo));// Detail screen: same tag, and Flutter tweens the bounds// between them automatically on push and on pop.Hero(tag: 'photo-${photo.id}', child: FullImage(photo));One practical gotcha with Flutter's Hero: the tag has to be unique per source, which is why the id is baked in above. Reuse the same tag for two visible widgets and you'll get an assertion, not a graceful fallback. And the child on each side should be visually compatible — hero a fixed-aspect thumbnail into a wildly different aspect ratio and the tween will look like a stretch, because it is one.
The trap: shared-element transitions are only honest when the two elements are genuinely the same object. I've watched people hero-animate a list row's icon into a detail screen's header logo because "the movement looks cool." It does not look cool. It lies. The user's brain files "this icon became that logo" as a fact about your data, and when nothing downstream ever confirms that fact, the whole app starts to feel subtly untrustworthy in a way people feel but can't put words to. Motion makes promises. Keep them.
A weaker but still honest option is the container transform: a card on a list morphs its bounds and content into the detail surface. Same principle — the card is the screen you're now on, without needing a single pixel-matched element inside it. Reach for it when a true hero isn't available but the spatial relationship is real. Flutter's animations package ships an OpenContainer that implements this pattern if you don't want to hand-roll the bounds tween.
This is the property that separates apps that feel alive from apps that feel like a wax museum. A user must be able to interrupt any animation mid-flight and have it do the sane thing. If your motion can't handle being second-guessed, it isn't finished.
Here's the one that taught me the rule. On a project last year we had a bottom sheet that animated up over 250ms. If the user tapped the scrim to dismiss it while it was still opening, the dismiss got queued behind the open. So the sheet would finish sliding all the way up, sit there for a beat like it was thinking about it, then slide back down. It looked broken because it was broken. The animation owned the interaction instead of serving it. What made it worse: it only reproduced when you were fast, so it sailed through QA and showed up in the wild as "sometimes the sheet does a weird thing," which is the least debuggable bug report in existence.
The fix is to treat animations as reversible from their current value, not as fire-and-forget sequences that must play to the end.
// Wrong: restart from the top, ignoring where we are.void dismiss() => controller.reverse(from: 1.0);// Right: reverse from wherever we currently are, so a// half-open sheet closes smoothly from its half-open// position, with no jump and no queued full-play.void dismiss() => controller.reverse();// And when handing off from a gesture to a spring, seed// the simulation with the live velocity so there's no// visible seam between the user's finger and the physics.void onDragEnd(DragEndDetails d) { final v = d.primaryVelocity! / sheetHeight; controller.fling(velocity: v < 0 ? -2 : 2);}Interruptibility is where causality and physics pay off together. If your motion is spring-based and value-driven instead of duration-and-keyframe based, interruption comes almost for free: you retarget the spring to a new endpoint and it flows there from its current position and velocity, no seam. If your motion is a hardcoded 300ms tween that must play start to finish, every interruption is a special case you hand-handle, and you will miss some, and the ones you miss will feel janky. The architecture of your animation decides whether it can be gracious when the user changes their mind. This is the strongest practical reason to prefer AnimationController value-driven motion over chained Future.delayed sequences: the controller always knows its current value, so reversal is trivial.
Test for this on purpose. For every non-trivial transition I mash it: open-close-open-close as fast as my thumb goes, tap through it mid-flight, fling it both directions. If anything snaps, queues, or double-plays, it isn't done, no matter how good it looks on the first clean run.
Newer phones render at 120fps, and that fact quietly makes apps worse. Not because high refresh rates are bad, but because they remove the last natural constraint. When everything is buttery, there's no performance pain to stop a team from adding the twelfth pointless transition. Smoothness becomes the alibi for excess.
Motion needs a budget, and the budget is measured in the user's attention, not the GPU's headroom. A few limits I hold to:
The honest test was never "does it run at 120fps." It's "if I turned this animation off, would anyone be confused?" If the answer is no, the animation was answering no question, and a high refresh rate just means you get to be wrong more smoothly. Smoothness is table stakes. Meaning is the product. If you do want a hard number to watch, keep an eye on jank in the Flutter DevTools timeline — dropped frames on a transition usually mean you're animating something expensive (a shadow, a blur, an unclipped Opacity) that a cheaper approach would render for free.
A real slice of users run with prefers-reduced-motion enabled — for vestibular disorders, motion sickness, or plain preference. Large slides, parallax, scale-and-spin, and zoom transitions can make these people physically nauseous. This isn't a nice-to-have. For some users it's the line between using your app and closing it and not coming back, and in many contexts it's an accessibility obligation, not a courtesy.
The lazy reading is "reduced motion means no motion, so disable everything." That's wrong, and it throws away the communication you spent all this effort building. Reduced motion means reduce the vestibular triggers — the large positional movement across the visual field — while keeping the motion that answers questions.
The mapping I use:
final reduce = MediaQuery.of(context).disableAnimations;// A slide-up detail becomes a cross-fade under reduced// motion: same "you moved to a new screen" meaning, none// of the nauseating positional travel.Widget transition(Widget child, Animation<double> a) { if (reduce) return FadeTransition(opacity: a, child: child); return SlideTransition( position: Tween(begin: const Offset(0, 1), end: Offset.zero) .animate(a), child: child, );}In Flutter, MediaQuery.of(context).disableAnimations reflects the OS-level setting, so you don't have to build your own toggle — you just have to respect the one the platform already exposes. Build the reduced-motion path as a first-class branch, not an afterthought where you slap if (reduce) return; at the top and ship a jarring instant cut. The entire premise here is that motion communicates. When you reduce it, you're switching to a quieter dialect. You are not going mute.
Most apps don't need more motion. They need less of it, aimed better. Here's the pass I run over a build, and it deletes far more than it ever adds:
Run this pass once and you'll be surprised how much comes out. Run it as a habit and you'll stop putting the junk in to begin with, which is the real win.
disableAnimations, cut the vestibular triggers, keep the meaning.The bar was never "does it look premium," a phrase that has not once helped me ship a better app. The bar is: does this movement answer a question the user is already asking? Stop making your app mumble. Say fewer things, and mean all of them.