Skip to content
César Venzac

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.

2 min readmotion, craft

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.

linearNo acceleration. Reads mechanical.
easeThe CSS default. Lags at the start.
ease-outLeaves immediately, settles soft.
ease-in-outSymmetric. For A-to-B moves.
400ms

Push the duration to 2000ms. The difference between these is obvious there and still present — just below conscious notice — at 200ms.

The only honest way to choose a curve is to watch them side by side at the real distance.

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.

Notebook pages from working this out. Scroll sideways.

A usable default set

Three curves cover nearly everything. Name them by intent, not by shape:

app/globals.css
@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.

components/sheet.tsx
<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.