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:
- Style resolution (main thread, once per state change): The
animation-timing-functionortransition-timing-functionvalue is parsed, the curve coefficients are extracted, and the animation is registered with the compositor. - Per-frame sampling (compositor thread, every vsync): The compositor calculates the current normalized time
t, evaluates the timing function attto get the progressp, then interpolates the property value asfrom + (to - from) * p. Forcubic-bezier(), this requires solving for the Bernstein polynomial — a fast operation the compositor handles without touching the main thread. - 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.
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 —
transformtransitions 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
transformis 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 tocubic-bezier(). Fallback for unsupporting browsers degrades toeaseautomatically.
DevTools workflow: auditing easing curves
Mis-tuned easing is difficult to spot in source code but immediately visible in DevTools.
Chrome DevTools
- Open DevTools and go to More Tools → Animations (or press
Ctrl+Shift+P→ “Show Animations”). - Trigger the animation on the page. The Animations panel captures it and shows each animated property as a horizontal timeline row.
- Click the curve icon next to any row to open the easing editor — a live cubic-bezier preview with draggable handles.
- Adjust the handles to find a curve that reads correctly, then copy the
cubic-bezier()values back into your CSS. - 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.
- Enable Rendering → Frame Rendering Stats overlay to see the live fps counter and GPU rasterization indicator during animation playback.
Firefox DevTools
- Open DevTools → Inspector → Animations tab.
- Hover any animated element — the panel shows its active animations, durations, and timing functions.
- Click the easing curve thumbnail to open an editable bezier editor.
- 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.
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: noneandanimation: 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
transformandopacityare 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+.
Related
- How to calculate cubic-bezier values for natural motion — the maths behind Bernstein polynomials and how to derive curves from physical spring parameters
- Keyframe architecture and state mapping — how per-keyframe timing functions interact with
@keyframesstop ordering - CSS transitions vs animations — choosing the right execution model before applying an easing curve
- Hardware-accelerated properties — the full list of compositor-safe properties where easing runs off the main thread
- Compositor-only property optimization — how the GPU compositing layer interacts with animated easing values