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:
| Curve | Use case | Feel |
|---|---|---|
ease-out | Elements entering the screen | Snappy, decisive |
ease-in-out | Elements moving between two states | Smooth, symmetrical |
cubic-bezier(0.16, 1, 0.3, 1) | Sheets, drawers, large surfaces | Soft landing |
linear | Continuous 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.