Easing is a decision, not a default
Most interfaces animate on whatever curve the library shipped with. That curve is a guess about your content, and it's usually wrong.
There's a moment in every project where someone adds transition: all 300ms ease to a base class, and from then on the whole product moves at one speed. It works. Nobody files a bug. And the interface quietly feels like a slideshow.
The problem isn't the duration. It's that ease — cubic-bezier(0.25, 0.1, 0.25, 1) — accelerates into the movement and decelerates out of it. That's the right shape for something travelling between two points under its own power. It's the wrong shape for almost everything an interface does, because most interface motion is a response to input that already happened.
Try it
Same distance, same duration, four curves. Watch the start of each one — that's where the difference lives.
Push the duration to 2000ms. The difference between these is obvious there and still present — just below conscious notice — at 200ms.
const CURVES = {
linear: [0, 0, 1, 1], // mechanical
ease: [0.25, 0.1, 0.25, 1], // CSS default — lags at the start
easeOut: [0.22, 1, 0.36, 1], // responds immediately, settles soft
easeInOut: [0.83, 0, 0.17, 1], // symmetric, for A-to-B moves
};
// Animate transform, never `left`: the wrapper spans the dot's whole
// range, so moving it by its own width carries the dot end to end.
<motion.div
key={run} // remounting is the cheapest replay
className="absolute left-0.5 w-[calc(100%-16px)]"
initial={{ transform: "translateX(-100%)" }}
animate={{ transform: "translateX(0%)" }}
transition={{ duration: 0.4, ease: CURVES.easeOut }}
>
<span className="absolute -right-3 size-3 rounded-full" />
</motion.div>;ease-out wins most of the time and it's not close. The element leaves immediately and settles gently, which reads as the interface responding to me rather than the interface deciding to move. ease-in is almost always wrong for entrances: the lag before anything happens feels like latency, even at 200ms.
A usable default set
Three curves cover nearly everything. Name them by intent, not by shape:
@theme {
/* Entering, or responding to input. Leaves fast, settles soft. */
--ease-out-quint: cubic-bezier(0.22, 1, 0.36, 1);
/* Moving between two known states — both ends matter equally. */
--ease-in-out-quint: cubic-bezier(0.83, 0, 0.17, 1);
/* Leaving. Nobody watches an exit, so get out of the way. */
--ease-in-quad: cubic-bezier(0.55, 0.085, 0.68, 0.53);
}Then the rule: entrances and exits are asymmetric. An element that enters over 240ms should leave in about 150ms. Entrances are content arriving and deserve to be read; exits are cleanup and should be over before you think about them.
<motion.div
initial={{ y: 12, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 8, opacity: 0 }}
transition={{
duration: isPresent ? 0.24 : 0.15,
ease: isPresent ? [0.22, 1, 0.36, 1] : [0.55, 0.085, 0.68, 0.53],
}}
/>The test
Turn the duration up to two seconds and watch it. Nearly every bad animation is obvious at 2000ms and merely slightly off at 200ms — and slightly off, repeated forty times a session, is what people mean when they say an app feels cheap.
If you can't tell the curves apart, you don't need four of them. Ship one and move on.