← Writing

Notes on Animating Interfaces Well

Good UI animation is mostly invisible — you notice when it’s missing or when it’s wrong, rarely when it’s right. After going through Emil Kowalski’s animation course notes, I rewrote a few of my own components with these rules in mind.

Rule 1: match duration to distance

Short movements should be fast. A tooltip appearing 8px away from its trigger shouldn’t take 300ms — that reads as sluggish. I use a small helper to scale duration with distance instead of hardcoding one number everywhere:

function durationForDistance(pixels: number) {
  const min = 120;
  const max = 320;
  const scale = Math.min(pixels / 200, 1);
  return Math.round(min + (max - min) * scale);
}

Rule 2: almost everything should ease out

Linear motion feels mechanical. For most UI transitions — modals opening, menus expanding, toasts sliding in — an ease-out curve feels the most natural because it mirrors how physical objects decelerate.

.menu {
  transform: translateY(-4px);
  opacity: 0;
  transition:
    transform 180ms cubic-bezier(0.16, 1, 0.3, 1),
    opacity 140ms ease-out;
}

.menu[data-state="open"] {
  transform: translateY(0);
  opacity: 1;
}

Rule 3: animate transforms and opacity, not layout

Animating width, height, top, or left forces the browser to recalculate layout on every frame. Transforms and opacity run on the compositor thread and stay smooth even under load:

/* Avoid */
.card {
  transition:
    width 200ms,
    left 200ms;
}

/* Prefer */
.card {
  transition:
    transform 200ms,
    opacity 200ms;
  transform: translateX(var(--x, 0));
}

Here’s a comparison of the easing curves I reach for most often:

CurveUse caseFeel
ease-outElements entering the screenSnappy, decisive
ease-in-outElements moving between two statesSmooth, symmetrical
cubic-bezier(0.16, 1, 0.3, 1)Sheets, drawers, large surfacesSoft landing
linearContinuous loops (spinners, marquees)Mechanical, on purpose

A simple React hook for entrance animation

function useEnterTransition(delayMs = 0) {
  const [mounted, setMounted] = useState(false);

  useEffect(() => {
    const id = setTimeout(() => setMounted(true), delayMs);
    return () => clearTimeout(id);
  }, [delayMs]);

  return mounted;
}

function FadeInCard({ children }: { children: React.ReactNode }) {
  const mounted = useEnterTransition();

  return (
    <div
      style={{
        opacity: mounted ? 1 : 0,
        transform: mounted ? "translateY(0)" : "translateY(6px)",
        transition: "opacity 180ms ease-out, transform 180ms ease-out",
      }}
    >
      {children}
    </div>
  );
}

If an animation makes you wait to use the interface, it’s too slow. Motion should feel like it’s keeping up with the user, never the other way around.

Worth reading in full: emilkowal.ski has a lot more detail on the physics-based side of this, including spring configuration for drag gestures.