Timing Functions & Easing Curves

Part of Core CSS Animation Fundamentals — the foundational reference for building smooth, composited CSS motion.

Timing functions define the velocity profile of a CSS animation or transition: how fast the animated value moves at each instant of the duration. Choosing the right easing curve is a rendering decision, not just an aesthetic one — the wrong curve on the wrong property forces the browser off the compositor thread and onto the main thread, where a single dropped frame breaks the 16 ms budget and produces visible jank.


What easing curves are and what rendering problem they solve

A timing function maps normalized time [0, 1] to a normalized progress value [0, 1] (or slightly outside that range for overshoot). The browser evaluates this mapping once per compositor frame — typically every 16.67 ms at 60 fps — and uses the result to interpolate the animated property’s value between its from and to states.

Without easing, every animation uses linear interpolation: equal progress per frame. Linear motion reads as mechanical and robotic because physical objects in the real world accelerate from rest and decelerate before stopping. Easing curves introduce non-uniform acceleration to simulate mass and friction, and when they are applied only to hardware-accelerated properties (transform and opacity), the entire interpolation pipeline runs on the GPU compositor thread — invisible to the main thread and immune to JavaScript execution pauses.


How the browser executes timing functions

The browser’s rendering pipeline processes an eased animation through three distinct phases:

  1. Style resolution (main thread, once per state change): The animation-timing-function or transition-timing-function value is parsed, the curve coefficients are extracted, and the animation is registered with the compositor.
  2. Per-frame sampling (compositor thread, every vsync): The compositor calculates the current normalized time t, evaluates the timing function at t to get the progress p, then interpolates the property value as from + (to - from) * p. For cubic-bezier(), this requires solving for the Bernstein polynomial — a fast operation the compositor handles without touching the main thread.
  3. Rasterization / compositing (GPU, every frame): The transformed or opacity-adjusted layer is drawn. No style recalculation, no layout, no paint — provided the animated property is compositor-only.

When you animate a property that is not compositor-safe (e.g. width, height, top, left, background-color), phases 1 and 2 still run, but the result is fed back into the main thread’s style and layout phases before rasterization. Every frame triggers a full style recalculation, which competes directly with JavaScript execution and can cause jank.

Browser rendering pipeline for eased CSS animations Three lanes: Main Thread (style resolve), Compositor Thread (curve sampling), GPU (rasterize/composite). Arrows show data flow. Compositor-safe properties skip back to the main thread; layout-affecting properties loop back through style and layout. Style Resolve Evaluate cubic-bezier(t) Interpolate transform / opacity compositor-safe? YES NO layout+paint each frame Rasterize + Composite Frame to screen (≤16 ms) ↻ repeated every vsync (16.67 ms at 60 fps) Main Thread Compositor Thread GPU
Easing curve evaluation in the browser rendering pipeline. Compositor-safe properties (transform, opacity) stay on the compositor thread every frame. Layout-affecting properties loop back to the main thread, risking jank.

Property and API reference

Property / Function Accepted values Compositing tier Notes
animation-timing-function linear, ease, ease-in, ease-out, ease-in-out, cubic-bezier(), steps(), linear() Composite (on transform/opacity) Per-keyframe override wins over element-level declaration
transition-timing-function Same as above Composite (on transform/opacity) Applied per triggered transition
cubic-bezier(x1, y1, x2, y2) x1, x2 in [0, 1]; y1, y2 unbounded Composite Y outside [0, 1] produces overshoot/undershoot
steps(n, direction) n: positive integer; direction: start, end, none, both Composite Discrete; no interpolation between steps
linear(p0, p1 %, p2 %, …) Stop values with optional percentages Composite CSS Easing Level 2; useful for spring/bounce curves
ease Shorthand for cubic-bezier(0.25, 0.1, 0.25, 1.0) Composite Browser default
ease-in-out Shorthand for cubic-bezier(0.42, 0, 0.58, 1.0) Composite Symmetric acceleration + deceleration

Annotated code examples

1. Standard easing token system with compositor-safe transition

Store easing curves as CSS custom properties so they can be audited, overridden per theme, and reused across every component.

/* Define the easing vocabulary once in :root */
:root {
  --ease-standard:    cubic-bezier(0.4, 0, 0.2, 1);   /* Material standard */
  --ease-decelerate:  cubic-bezier(0, 0, 0.2, 1);     /* Elements entering the screen */
  --ease-accelerate:  cubic-bezier(0.4, 0, 1, 1);     /* Elements leaving the screen */
  --ease-spring:      cubic-bezier(0.34, 1.56, 0.64, 1); /* Slight overshoot */
}

.card {
  /* Compositor-safe: transform never triggers layout or paint */
  transform: translateY(0);
  transition: transform 0.3s var(--ease-decelerate);
  /* will-change hints the compositor to promote this layer */
  will-change: transform;
}

.card:hover {
  transform: translateY(-4px);
}

/* Remove the hint after interaction to free GPU memory */
.card:not(:hover) {
  will-change: auto;
}

/* Accessibility: honour reduced-motion preference */
@media (prefers-reduced-motion: reduce) {
  .card {
    transition: none;
    will-change: auto;
  }
}

Rendering Impact: composite — transform transitions run entirely on the compositor thread. No style recalculation or layout phase fires per frame.


2. Per-keyframe easing for multi-stage sequences

Each @keyframes stop can declare its own animation-timing-function. The declared curve applies from that stop to the next, not from the stop backwards.

@keyframes bounce-in {
  0%   {
    transform: scale(0.6);
    animation-timing-function: var(--ease-accelerate); /* fast approach */
  }
  70%  {
    transform: scale(1.08);
    animation-timing-function: var(--ease-decelerate); /* slow settle with overshoot */
  }
  85%  {
    transform: scale(0.97);
    animation-timing-function: var(--ease-standard);  /* tighten to rest */
  }
  100% {
    transform: scale(1);
  }
}

.dialog {
  animation: bounce-in 0.45s both;
  /* Compositor-safe: only transform is animated */
}

@media (prefers-reduced-motion: reduce) {
  .dialog {
    animation: none;
    /* Apply the final state immediately for motion-sensitive users */
    transform: scale(1);
  }
}

Rendering Impact: composite — only transform is modified. All three easing segments are evaluated on the compositor thread.


3. Spring-physics approximation with linear()

cubic-bezier() can only represent a single arc. The CSS linear() function (Easing Level 2) accepts multiple progress stops, enabling arbitrary piecewise curves — practical for spring and bounce shapes that previously required JavaScript.

:root {
  /*
   * Approximate a spring with: fast overshoot at ~55%, brief bounce at ~75%,
   * settle at 100%. Values derived from a spring simulation export.
   * Supported: Chrome 113+, Firefox 112+, Safari 17.2+
   */
  --ease-spring-bounce: linear(
    0, 0.009, 0.035 2.1%, 0.141 4.4%,
    0.723 12.4%, 1.024 16%,
    1.109 18.8%, 1.134 20.6%,
    1.114 22.3%, 1.037 24.4%,
    1.003 25.9%, 0.983 27.4%,
    0.99 29%, 1.003 32.4%,
    1
  );
}

.tooltip {
  transform: scale(0);
  transition: transform 0.5s var(--ease-spring-bounce);
  will-change: transform;
}

.tooltip.is-visible {
  transform: scale(1);
}

@media (prefers-reduced-motion: reduce) {
  .tooltip {
    transition: opacity 0.15s linear;
    transform: scale(1); /* skip spring, fade instead */
  }
}

Rendering Impact: composite — linear() evaluates on the compositor thread identically to cubic-bezier(). Fallback for unsupporting browsers degrades to ease automatically.


DevTools workflow: auditing easing curves

Mis-tuned easing is difficult to spot in source code but immediately visible in DevTools.

Chrome DevTools

  1. Open DevTools and go to More Tools → Animations (or press Ctrl+Shift+P → “Show Animations”).
  2. Trigger the animation on the page. The Animations panel captures it and shows each animated property as a horizontal timeline row.
  3. Click the curve icon next to any row to open the easing editor — a live cubic-bezier preview with draggable handles.
  4. Adjust the handles to find a curve that reads correctly, then copy the cubic-bezier() values back into your CSS.
  5. For frame-rate issues: open the Performance panel, click Record, trigger the animation, then stop recording. In the flame chart, look for:
    • Long tasks (red corner) on the main thread during the animation — indicates layout-affecting properties are being animated.
    • “Recalculate Style” or “Layout” tasks repeating every 16 ms — same diagnosis.
    • A clean compositor animation shows no main-thread activity during the animation rows.
  6. Enable Rendering → Frame Rendering Stats overlay to see the live fps counter and GPU rasterization indicator during animation playback.

Firefox DevTools

  1. Open DevTools → Inspector → Animations tab.
  2. Hover any animated element — the panel shows its active animations, durations, and timing functions.
  3. Click the easing curve thumbnail to open an editable bezier editor.
  4. In the Performance panel, record and look for “Animation Frame” markers — purple markers on the compositor timeline confirm the animation is off the main thread.

Failure modes and fixes

Problem: Animation looks mechanical and abrupt. Root cause: animation-timing-function: linear is the browser default when the property is unset. Uniform velocity reads as artificial for UI elements. Fix: Always declare an explicit easing. Use var(--ease-standard) as the baseline and tune from there.


Problem: Frame drops visible in the Performance panel during a transition. Root cause: The animated property is not compositor-safe. Animating width, height, padding, margin, top, or left triggers style recalculation and layout on the main thread every frame — far more expensive than a compositor sample. Fix: Animate transform: scaleX() instead of width, transform: translate() instead of top/left. See hardware-accelerated properties for the full safe-list and equivalent transform mappings.


Problem: Velocity “snaps” at a keyframe boundary mid-sequence. Root cause: Adjacent @keyframes segments use timing functions with mismatched exit and entry velocities. The first derivative of the curve is discontinuous at the boundary, producing a perceptible jump. Fix: Match the velocity at the shared boundary. If segment A ends fast (ease-in), segment B should begin fast (ease-out). Use an intermediate keyframe stop at the boundary to soften the transition, or switch to a linear() approximation across the entire sequence.


Problem: cubic-bezier() y-values outside [0, 1] cause the element to visually overshoot but a sibling property clips at its boundary value. Root cause: Overshoot (y > 1) and undershoot (y < 0) work on continuous numeric values like those in transform, but clamped properties like opacity (bounded 0–1), border-radius, or colour channels will clamp at their limits. Fix: Reserve overshoot curves for transform-only animations. For multi-property transitions involving clamped properties, keep Y within [0, 1].


Problem: will-change: transform is set globally and never removed, causing persistent GPU layer promotion across all instances. Root cause: will-change reserves a GPU texture for the element. On pages with many instances (e.g. a list of 500 rows), this exhausts GPU memory and degrades overall compositing performance. Fix: Apply will-change just before the animation triggers (e.g. on :hover or in a JS transitionstart handler) and remove it when the animation ends via transitionend. See layer promotion and will-change strategy for the full pattern.


Easing curve visual reference

The diagram below shows the four most-used easing families plotted as progress-over-time curves. The shaded area between ease-in-out and linear represents the perceptual “snap zone” — the region where most UI motion reads as mechanical.

CSS easing curve comparison Four easing curves plotted on a time (X) vs progress (Y) graph. Linear: straight diagonal. Ease: slow start, fast middle, slow end. Ease-in-out: symmetric S-curve. Spring (cubic-bezier overshoot): rises above 1.0 before settling. Time → Progress → 0 25% 50% 75% 100% 0 25% 50% 75% 100% linear ease ease-in-out spring (overshoot)
Easing curve comparison. Spring overshoot rises above 100% progress — only safe on unclamped properties like transform.

Accessibility and reduced-motion

The prefers-reduced-motion: reduce media query must be treated as a hard gate, not a polite suggestion. Users who enable this setting may have vestibular disorders where large or rapid motion triggers physical disorientation or nausea.

The right response depends on what the animation communicates:

  • Purely decorative motion (hover lift, entrance fade): remove entirely with transition: none and animation: none.
  • State-change feedback (loading spinner, progress indicator): replace with an opacity crossfade at a reduced duration (≤ 150 ms). The information is preserved; the motion is eliminated.
  • Physics-based spring animations: collapse to a step change — apply the final value immediately. The spring is an aesthetic enhancement, not functional.
/* Default: full spring entrance */
.panel {
  animation: slide-up 0.45s var(--ease-spring) both;
}

@keyframes slide-up {
  from { transform: translateY(24px); opacity: 0; }
  to   { transform: translateY(0);    opacity: 1; }
}

/* Reduced-motion: preserve the fade, remove the translate */
@media (prefers-reduced-motion: reduce) {
  .panel {
    animation: fade-in 0.15s linear both;
  }

  @keyframes fade-in {
    from { opacity: 0; }
    to   { opacity: 1; }
  }
}

Rendering Impact: composite — both transform and opacity are compositor-safe. The reduced-motion variant drops to opacity-only, remaining compositor-safe at a shorter duration.

For steps() animations (e.g. sprite sheets): under prefers-reduced-motion, show only the first or last frame by replacing with animation: none and setting the appropriate background-position or transform directly.


Frequently Asked Questions

When should I use cubic-bezier over steps() or linear?

Use cubic-bezier() for organic, physics-based motion requiring smooth acceleration and deceleration — UI transitions, modal entrances, card hovers. Reserve steps() for sprite-sheet animations or discrete state changes where intermediate frames must not appear. Use linear only for continuous background processes (progress bars, rotating loaders) where uniform velocity is intentional.

How do easing curves affect the browser rendering pipeline?

When applied to transform and opacity, easing curves are sampled entirely on the compositor thread per frame — the main thread is not involved. Applying easing to layout-affecting properties like width, top, or left forces full style recalculation and layout on the main thread every frame, competing with JavaScript and causing dropped frames.

Can I update an easing curve at runtime without triggering a reflow?

Yes. Store the cubic-bezier value in a CSS custom property (e.g. --ease-out) and update it via element.style.setProperty('--ease-out', 'cubic-bezier(…)'). The browser resolves the updated cascade before handing off to the compositor — no layout or paint phase fires.

Why does my per-keyframe easing produce a velocity snap at a keyframe boundary?

Each @keyframes segment gets its own animation-timing-function applied from that stop to the next. If adjacent segments use curves with mismatched velocities at their shared boundary, the element appears to snap. Match the entry/exit velocities of adjacent curves, or use an intermediate keyframe to smooth the join.

What is linear() and when does it replace cubic-bezier()?

The linear() function (CSS Easing Level 2) accepts multiple stop values with optional percentage positions, letting you define arbitrary piecewise-linear curves — practical for spring and bounce effects that a single cubic-bezier() arc cannot express. It is compositor-safe and supported in Chrome 113+, Firefox 112+, and Safari 17.2+.