How to Calculate cubic-bezier for Natural Motion

Part of Timing Functions & Easing Curves in Core CSS Animation Fundamentals.

Problem: UI transitions that feel robotic or floaty

Default CSS keywords — linear, ease, ease-in-out — apply mathematically clean velocity curves that do not match how physical objects actually move. Elements slide without mass, snap to rest without deceleration, or overshoot without damping. The perceptual gap is sharpest on high-DPI displays and 120 Hz panels, where the extra frame resolution makes velocity discontinuities immediately visible.

Root cause: control-point values that ignore physical inertia

A CSS cubic-bezier is defined as cubic-bezier(x1, y1, x2, y2). The two control points P1 (x1, y1) and P2 (x2, y2) shape the interpolation curve between the start and end state. The X coordinates govern how quickly time progresses through the animation — they must remain within [0, 1]. The Y coordinates govern the output value at each time step and may exceed [0, 1] to create overshoot when applied to hardware-accelerated properties.

Natural motion requires a strong initial acceleration (high early velocity) followed by a deceleration phase that settles asymptotically at rest. When control points are chosen arbitrarily — or left at a generic keyword default — the resulting velocity curve produces flat plateaus or abrupt stops that have no physical analogue. The browser compositor samples this curve every 16.67 ms at 60 fps; every imprecise sample compounds the perceptual mismatch.

Cubic-bezier control point geometry A coordinate plane showing how P1 and P2 control points shape the cubic-bezier curve between P0 (0,0) and P3 (1,1). P1 governs initial acceleration; P2 governs the deceleration towards rest. time value 0 1 0 1 P0 (0,0) P3 (1,1) P1 (x1, y1) controls initial acceleration P2 (x2, y2) controls deceleration to rest cubic-bezier(0.25, 0.1, 0.25, 1.0) — the CSS keyword "ease"

Step-by-step: mapping spring dynamics to control points

Deriving control points from physical intuition follows a consistent four-step process.

  1. Choose the motion character. Decide between: a smooth deceleration (standard UI), a fast entry with slow settle (modal or sheet), or an elastic overshoot (micro-interaction feedback). Each maps to a different region of the control-point space.

  2. Set P1 to control initial acceleration. A low x1 (close to 0) and a low y1 produces a slow ramp — appropriate for fades. A low x1 with a high y1 fires an immediate velocity spike — matching the feel of a physical tap or spring release. Raising x1 delays the acceleration, producing a “wind-up” effect.

  3. Set P2 to control deceleration. A high x2 (close to 1) combined with y2 = 1.0 creates a long, smooth approach to rest — the characteristic of Material Design’s standard easing. Reducing x2 sharpens the final settle. For overshoot, set y2 = 1.0 and push y1 above 1.0 (e.g. 1.56).

  4. Tune duration for perceived mass. Heavier visual elements feel wrong at fast durations even with a correct curve. Establish a base duration tied to the element’s visual weight: small badges at 150–200 ms, panels and sheets at 300–400 ms, full-screen transitions at 450–600 ms.

Reference control-point table

Behaviour x1 y1 x2 y2 Duration range
CSS ease 0.25 0.1 0.25 1.0 200–400 ms
Material standard 0.4 0.0 0.2 1.0 250–400 ms
Elastic overshoot 0.34 1.56 0.64 1.0 400–600 ms
Sharp deceleration 0.0 0.0 0.2 1.0 150–300 ms
Symmetrical ease-in-out 0.42 0.0 0.58 1.0 300–500 ms

Production code pattern

The snippet below demonstrates the two most common natural-motion variants alongside the spring-to-bezier utility that generates them programmatically. Every variant includes a prefers-reduced-motion fallback — this is non-negotiable for accessible production code.

/* Standard natural deceleration — compositor-safe */
.element {
  /* Compositor promotion: keeps animation off the main thread */
  will-change: transform;
  /* Fast entry, long smooth settle: suits panels, drawers, modals */
  transition: transform 0.35s cubic-bezier(0.4, 0.0, 0.2, 1.0);
}

/* Elastic overshoot — only valid on compositor properties */
/* Y1 > 1.0 overshoots the target value before settling */
.element--spring {
  will-change: transform;
  transition: transform 0.5s cubic-bezier(0.34, 1.56, 0.64, 1.0);
}

/* Reduced-motion: remove animation entirely for vestibular safety */
@media (prefers-reduced-motion: reduce) {
  .element,
  .element--spring {
    transition: none;
  }
}

Rendering impact: composite — both variants animate transform, which the GPU compositor handles without touching layout or paint.

/**
 * Spring-to-bezier approximation utility.
 * Maps stiffness and damping to cubic-bezier control points.
 * This is a curve-fitting approximation, not an exact physics simulation.
 * For true spring physics use the Web Animations API with a custom easing function.
 *
 * @param {number} stiffness - Spring tension (1–300; higher = faster initial velocity)
 * @param {number} damping   - Friction (1–100; higher = less overshoot)
 * @returns {string} CSS cubic-bezier() declaration
 */
function springToBezier(stiffness = 100, damping = 15) {
  // x1: delay before peak velocity — damping reduces the delay
  const x1 = Math.min(0.9, 0.2 + damping / 100);
  // y1: peak output value — stiffness drives how high it climbs (overshoot if > 1)
  const y1 = Math.min(2.0, 0.1 + stiffness / 200);
  // x2: time at which deceleration begins — inversely scaled by damping
  const x2 = Math.max(0.1, 0.25 - damping / 200);
  // y2: always ends at 1.0 (the target state)
  const y2 = 1.0;

  return `cubic-bezier(${x1.toFixed(2)}, ${y1.toFixed(2)}, ${x2.toFixed(2)}, ${y2.toFixed(2)})`;
}

// springToBezier(100, 15)  → "cubic-bezier(0.35, 0.60, 0.18, 1.00)"
// springToBezier(200, 5)   → "cubic-bezier(0.25, 1.10, 0.23, 1.00)"  (overshoot)
// springToBezier(50,  80)  → "cubic-bezier(0.90, 0.35, 0.10, 1.00)"  (heavy damping)

Rendering impact: main-thread at calculation time (JavaScript), then composite once the resulting CSS value is applied to transform.

Verification checklist

Constraints and trade-offs

  • X-axis strictly bounded. Browsers clamp or ignore x1 or x2 values outside [0, 1]. Some implementations silently fall back to linear; others produce console warnings. Always validate before shipping.
  • Overshoot on non-compositor properties causes layout overflow. Using y1 > 1.0 on width, height, top, or left temporarily pushes the geometry beyond its intended bounds, potentially causing scrollbar flicker or clipping by a parent overflow: hidden container. See avoiding layout thrashing in CSS animations for safe property alternatives.
  • No perfect spring approximation. A true damped spring equation has infinite theoretical duration. Cubic-bezier forces a hard end at the specified duration, which can produce a slight snap at the tail of high-stiffness, low-damping values. For pixel-perfect spring physics, use KeyframeEffect with the Web Animations API.
  • Mobile frame scheduling varies. WebKit and Blink handle composited animation differently on 60 Hz vs 120 Hz panels. A curve that feels smooth on desktop may appear clipped on ProMotion displays if the duration is too short for the refresh rate to express the easing shape. Test at both refresh rates.
  • will-change has memory cost. Each promoted layer consumes GPU texture memory. Declare will-change just before the animation, then remove it after it completes — do not apply it globally in a reset stylesheet. See layer promotion and will-change strategy for budget guidance.

FAQ

How do I convert a physical spring equation to a CSS cubic-bezier? Cubic-bezier cannot perfectly represent a damped spring — a spring has continuous velocity that can overshoot indefinitely, while cubic-bezier is capped at a fixed duration. For a close approximation, map the damping ratio to x1 and the stiffness factor to y1, then validate by plotting the velocity curve. For true spring physics, use the Web Animations API with a custom easing function.

Can cubic-bezier Y-axis values exceed 1.0? Yes. Values greater than 1.0 or less than 0 on the Y-axis create overshoot or undershoot effects, simulating elastic bounce. The X-axis must remain strictly within [0, 1] to prevent infinite evaluation and browser timing errors.

Why does my calculated curve feel different on mobile devices? Mobile browsers use different frame scheduling and touch-input latency compensation. GPU compositing behaviour also varies across WebKit and Blink, altering how sub-pixel rendering interpolates the easing curve. Test on target devices and adjust duration (±50 ms) to compensate for perceived speed.

What happens if I apply an overshoot curve to a layout-triggering property? The geometry temporarily exceeds its intended bounds, causing scrollbar flicker, visible clipping by overflow: hidden parents, or cumulative layout shifts. Restrict overshoot to transform and opacity.