The Art of CSS Motion

Easing curves, staggered animations, and choreography as a design tool — how to think about motion the way a designer thinks about type and space.

Motion is a design material. Most developers treat it as a finishing touch — something you add after the layout is done. But the best interfaces treat motion the way they treat type: as a primary element with its own grammar, hierarchy, and intention.

This is about learning that grammar.

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.

cubic-bezier() gives you full control:

.element {
  transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
}

That specific curve — overshoot past 1.0 on the y-axis — produces a spring-like bounce at the end. The element arrives at its destination and slightly overshoots before settling. It feels physical.

Understanding the 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)

A few curves worth knowing:

/* Snappy entrance — fast start, gentle landing */
cubic-bezier(0.22, 1, 0.36, 1)

/* Spring — overshoots and settles */
cubic-bezier(0.34, 1.56, 0.64, 1)

/* Anticipation — slight pullback before moving */
cubic-bezier(0.36, 0, 0.66, -0.56)

/* Smooth deceleration — for elements entering the screen */
cubic-bezier(0, 0, 0.2, 1)

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.

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.

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.

.card:nth-child(1) { animation-delay: 0ms; }
.card:nth-child(2) { animation-delay: 60ms; }
.card:nth-child(3) { animation-delay: 120ms; }
.card:nth-child(4) { animation-delay: 180ms; }

But 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);
}

Set --i on each element:

<div class="card" style="--i: 0">...</div>
<div class="card" style="--i: 1">...</div>
<div class="card" style="--i: 2">...</div>

The stagger is now a single number you can tune globally. Change 60ms to 40ms and every card responds.

Direction and hierarchy in stagger

Not all staggers are linear. The direction of the stagger communicates hierarchy:

Top to bottom — reading order, content flowing in naturally Bottom to top — rare, creates urgency, feels like things are rising Center outward — emphasizes the middle element as most important Random — organic, chaotic, used for particle-like effects

/* Stagger from center outward */
.card {
  --distance-from-center: abs(calc(var(--i) - var(--center)));
  animation-delay: calc(var(--distance-from-center) * 50ms);
}

CSS abs() is available in modern browsers. Combined with a --center variable set to half the total count, elements near the center animate first and 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 fixes this:

  • forwards — holds the final keyframe state after animation ends
  • backwards — applies the first keyframe during the delay period
  • both — does both
.card {
  animation: fade-up 0.5s ease both;
  animation-delay: calc(var(--i) * 60ms);
}

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

With both, the card is invisible during its delay (applying the from keyframe), then animates in, then holds at opacity: 1, translateY(0).

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:

@keyframes reveal {
  from {
    opacity: 0;
    transform: translateY(20px);
  }
}

.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.

The prefers-reduced-motion rule

Motion can cause problems for people with vestibular disorders. prefers-reduced-motion is not optional:

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

This is a blanket override — all animations collapse to near-instant. Some designers prefer a more nuanced approach: keeping functional transitions (like a focus ring appearing) but removing decorative ones (like a page entrance animation).

@media (prefers-reduced-motion: reduce) {
  .decorative-animation {
    animation: none;
  }
}

Either way, respect the preference. Motion that can’t be turned off is an accessibility failure.

Motion as design language

The best motion design isn’t noticed. It’s felt.

When a modal opens with a slight upward drift and a soft spring at the end, users don’t think “nice easing.” They think the interface feels responsive and alive. When a list staggers in from top to bottom, users don’t parse the delay values — they just feel like the content arrived in a natural, readable order.

Motion communicates:

  • Speed — how fast something responds tells you how much it cares about your action
  • Weight — heavy things move slowly, light things move quickly
  • Relationship — elements that move together belong together
  • Hierarchy — what moves first is most important

These are design decisions, not developer decisions. But they’re implemented in code. That’s the design engineer’s territory: understanding motion as a design language and having the technical fluency to execute it precisely.

The easing curve is a sentence. Learn to write it well.