The first time I noticed motion done right in a UI, I couldn’t explain why it felt different. The modal didn’t just appear — it arrived. I opened DevTools and looked at the easing curve. cubic-bezier(0.34, 1.56, 0.64, 1). That slight overshoot past 1.0. It felt physical, like something had weight and then settled.

I’ve been thinking about that curve ever since.

Easing is everything

The single biggest difference between animation that feels cheap and animation that feels crafted is the easing curve. Duration matters. But easing matters more.

The browser gives you five keywords: linear, ease, ease-in, ease-out, ease-in-out. These are fine as defaults and terrible as a final answer. Here’s what each actually looks like side by side:

ease vs spring vs anticipation
ease-in-out
snappy
spring
anticipation
/* Default — boring */
transition: transform 0.5s ease-in-out;

/* Snappy entrance — fast start, gentle landing */
transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);

/* Spring — overshoots and settles */
transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);

/* Anticipation — pulls back before moving */
transition: transform 0.5s cubic-bezier(0.36, 0, 0.66, -0.56);

cubic-bezier() gives you full control over four values:

cubic-bezier(x1, y1, x2, y2)
  • x1, y1 — the first control point (affects the start of the curve)
  • x2, y2 — the second control point (affects the end of the curve)
  • x values must stay between 0 and 1 (time)
  • y values can go outside 0–1 (producing overshoot)

The anticipation curve goes negative on y — the element moves backward slightly before accelerating forward. Used on a button press, it feels like something winding up. Used on a menu opening, it’s too much. Context matters.

Duration and the perception of weight

Duration communicates weight. Fast animations feel light. Slow animations feel heavy or important.

A general framework:

  • 50–100ms — micro-interactions: hover states, focus rings, checkbox ticks
  • 150–300ms — UI transitions: dropdowns, tooltips, tab changes
  • 300–500ms — layout changes: modals, drawers, page sections
  • 500ms+ — cinematic: hero entrances, loading sequences, brand moments

Breaking these rules is valid — but do it intentionally. A button hover that takes 400ms doesn’t feel crafted, it feels broken. I’ve seen this in production more times than I can count.

Staggered animations — choreography

Stagger is where individual animations become choreography. Instead of all elements moving at once, they move in sequence — each delayed slightly from the previous.

Hardcoded delays are brittle. CSS custom properties make stagger scalable:

.card {
  animation: fade-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--i) * 60ms);
}
Stagger — cards fading up in sequence
.card {
  animation: fade-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--i) * 80ms);
}

@keyframes fade-up {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
}

Set --i on each element via inline style or a loop. Change 80ms and every card responds — the stagger is a single number to tune globally. This is the pattern I reach for every time.

Direction matters in stagger

Not all staggers are linear. The direction communicates hierarchy:

Top to bottom — reading order, content flowing in naturally
Center outward — emphasizes the middle element as most important
Random — organic, used for particle-like effects

Center-out stagger
.card {
  --dist: abs(calc(var(--i) - var(--center)));
  animation: fade-up 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--dist) * 80ms);
}

CSS abs() is available in modern browsers. Set --center to half the total count and elements near the center animate first — edges follow.

animation-fill-mode: both

One of the most common animation bugs: elements flash into their pre-animation state before the animation starts (when there’s a delay), then snap to their final state after it ends.

animation-fill-mode: both fixes both sides — it applies the from keyframe during the delay period, and holds the to keyframe after the animation completes.

fill-mode: none vs both
none
both
/* Without — flashes visible during delay, snaps at end */
.bad {
  animation: fade-up 0.6s ease;
  animation-delay: 0.5s;
}

/* With both — invisible during delay, holds final state */
.good {
  animation: fade-up 0.6s ease both;
  animation-delay: 0.5s;
}

The top dot flashes visible during its delay — no backwards fill. The bottom dot starts invisible and holds its final position. Always use both when combining stagger delays with entrance animations.

Scroll-driven choreography

CSS scroll-driven animations change what choreography means. Instead of time-based stagger, you can stagger based on position in the viewport:

.card {
  animation: reveal linear both;
  animation-timeline: view();
  animation-range: entry 0% entry 30%;
}

Every card reveals itself as it enters the viewport — no JavaScript, no IntersectionObserver, no class toggling. The stagger happens naturally because cards enter the viewport at different times as the user scrolls.

prefers-reduced-motion

Some people have vestibular disorders where motion on screen causes genuine physical discomfort — dizziness, nausea. The OS-level setting exists for them. Worth putting this in your global stylesheet rather than remembering it per-component:

@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

I’ve seen this get flagged late in accessibility audits right before a launch. Easier to have it in the reset from the start.