Easing & Motion Design: Making CSS Motion Feel Intentional
The mechanics of CSS animation are straightforward: pick properties, set a duration, choose a timing function. What separates motion that feels polished from motion that merely moves is the design of those choices — how curves and durations relate to one another, which moments deserve emphasis, and how the system behaves when a user asks for less motion. This guide, part of CSS-Only Micro-Interactions & Animations, treats easing and timing as a design system: the vocabulary of curves, how they map onto intent, how to encode them as tokens, and how to apply them consistently across components.
Prerequisites — this guide assumes you can already:
- Write CSS transitions and keyframe animations (see CSS Transition Fundamentals).
- Read a
cubic-bezier()value as two control points. - Use custom properties for design tokens.
- Handle
prefers-reduced-motionin a media query.
The Core Concept: Timing Functions Map Time to Progress
Every CSS animation computes, for each frame, how far through its duration it is — the input progress, from 0 to 1. The timing function transforms that into output progress, which is what actually interpolates the animated property. linear maps input straight to output. Every other timing function reshapes the relationship: ease-out makes output race ahead early and crawl at the end; steps(4) holds output flat and jumps; a spring pushes output past 1 and back.
Every guide in this section adjusts one part of that pipeline. Choosing Animation Durations sets the denominator. Overshoot and Anticipation With cubic-bezier and Spring and Bounce Easing With linear() shape the curve. steps() Easing for Sprites and Typing makes the output discrete. Entry vs Exit Easing chooses different curves and durations for each direction of travel.
The four families of curves
The practical insight is that choosing easing is not choosing a favourite curve; it is classifying each motion by its job. An element arriving decelerates. An element leaving accelerates. An element moving between two visible positions — a toggle knob, a tab indicator — accelerates and decelerates symmetrically, because both ends of the path are on screen. And a small number of moments that deserve attention get an expressive curve. Once motions are classified, the curve follows.
Why a system beats one-off choices
Motion choices made one component at a time drift. The dropdown gets 0.3s ease, the modal 250ms ease-out, the toast .4s cubic-bezier(.17,.67,.83,.67) copied from a blog post, and the tooltip nothing at all. Each looked fine when it was built. Together they make an interface whose parts do not feel like they belong to the same product, and users perceive that inconsistency as a lack of polish even when they cannot name it.
A motion system fixes this the same way a colour system fixes ad hoc hex values. A small, named vocabulary — a handful of curves and a handful of durations — is paired into intents such as enter, exit, move and emphasis. Components choose an intent; the system decides the numbers. The benefits compound: consistency across components, one place to tune the feel of the whole product, and a single switch for reduced motion. It also makes design review faster, because the conversation moves from "should this be 280 or 300 milliseconds?" to "is this an entrance or a move?", which is a question with a clear answer.
The system does not have to be large, and it should not try to anticipate every future need. Most products need four or five intents, and new ones should be added only when a real component cannot be expressed with the existing set. What matters is that every animation in the codebase maps onto one of them, and that exceptions are rare, deliberate and documented.
Syntax and Parameters
| Timing function | Accepted values | Default / notes |
|---|---|---|
| Keywords | linear, ease, ease-in, ease-out, ease-in-out | ease is the initial value |
cubic-bezier(x1, y1, x2, y2) | x in 0–1; y any number | y outside 0–1 gives overshoot or anticipation |
linear(<point>#) | output numbers with optional input percentages | outputs unclamped; enables springs and bounces |
steps(n, <position>) | n ≥ 1; jump-start, jump-end, jump-none, jump-both | position defaults to jump-end |
step-start, step-end | keywords | steps(1, jump-start) / steps(1, jump-end) |
| Applied via | transition-timing-function, animation-timing-function, per-keyframe | per-keyframe values apply to the segment that follows |
The last row is an underused feature: animation-timing-function declared inside a keyframe applies to the segment from that keyframe to the next. That allows a single animation to ease out into a midpoint and ease in out of it, which is how complex multi-stage motion is built without multiple animations.
Step-by-Step Implementation: A Motion Token System
Step 1: define the curve vocabulary
:root {
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in: cubic-bezier(0.64, 0, 0.78, 0);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-emphasis: cubic-bezier(0.34, 1.56, 0.64, 1);
}
Step 2: define the duration scale
:root {
--dur-1: 90ms;
--dur-2: 160ms;
--dur-3: 240ms;
--dur-4: 340ms;
--dur-5: 480ms;
}
Step 3: map intents to pairs of curve and duration
:root {
--motion-enter: var(--dur-3) var(--ease-out);
--motion-exit: var(--dur-2) var(--ease-in);
--motion-move: var(--dur-3) var(--ease-in-out);
--motion-emphasis: var(--dur-4) var(--ease-emphasis);
--motion-feedback: var(--dur-1) linear;
}
Step 4: components reference intents only
.tab-indicator { transition: translate var(--motion-move); }
.toast { transition: translate var(--motion-enter), opacity var(--motion-enter); }
.button:active { transition: scale var(--motion-feedback); }
Step 5: one switch for reduced motion
@media (prefers-reduced-motion: reduce) {
:root {
--motion-enter: 1ms linear;
--motion-exit: 1ms linear;
--motion-move: 1ms linear;
--motion-emphasis: 1ms linear;
}
}
Because every component references an intent token, the reduced-motion variant of the entire interface is one media query. Components never need to know about the preference.
Annotated Production Example: A Segmented Control
A segmented control — a row of mutually exclusive options with a sliding indicator — uses three of the four families at once: a symmetric move for the indicator, fast feedback for the press, and a decelerating fade for the label colour.
<fieldset class="segmented">
<legend class="visually-hidden">View</legend>
<label><input type="radio" name="view" value="day" checked><span>Day</span></label>
<label><input type="radio" name="view" value="week"><span>Week</span></label>
<label><input type="radio" name="view" value="month"><span>Month</span></label>
<span class="segmented__indicator" aria-hidden="true"></span>
</fieldset>
.segmented {
--count: 3;
--index: 0;
position: relative;
display: grid;
grid-template-columns: repeat(var(--count), 1fr);
margin: 0;
padding: 0.25rem;
border: 0;
border-radius: 10px;
background: #e2e8f0;
}
/* The checked option publishes its index for the indicator to read. */
.segmented:has(input[value="week"]:checked) { --index: 1; }
.segmented:has(input[value="month"]:checked) { --index: 2; }
.segmented label { position: relative; z-index: 1; text-align: center; cursor: pointer; }
.segmented input { position: absolute; opacity: 0; }
.segmented span {
display: block;
padding: 0.4rem 0.75rem;
color: #475569;
transition: color var(--motion-enter); /* decelerating colour change */
}
.segmented input:checked + span { color: #0f172a; }
.segmented input:focus-visible + span { outline: 2px solid #4f46e5; outline-offset: 2px; border-radius: 6px; }
.segmented label:active span { scale: 0.96; transition: scale var(--motion-feedback); }
.segmented__indicator {
position: absolute;
inset-block: 0.25rem;
left: 0.25rem;
width: calc((100% - 0.5rem) / var(--count));
border-radius: 8px;
background: #ffffff;
box-shadow: 0 1px 3px rgb(15 23 42 / 0.15);
translate: calc(var(--index) * 100%) 0;
transition: translate var(--motion-move); /* symmetric: both ends on screen */
}
The indicator moves between two positions that are both visible, so it uses the symmetric curve. The label colour is a state arriving, so it decelerates. The press scale is feedback, so it is fast and linear. None of these values appears in the component — only intent names — so the component inherits any future tuning of the motion system automatically.
Motion Hierarchy and Choreography
A single well-eased transition is easy. A screen where six things change at once — a panel opens, its content fades in, a badge updates, a button disables, the background dims — is where motion design earns its name. Without a plan, everything animates simultaneously with the same curve and duration, and the result reads as one undifferentiated flash.
Establish a primary motion. In any state change, one element carries the meaning: the dialog appearing, the item being deleted, the card expanding. Give it the most noticeable motion — the longest duration in the change, the most travel. Everything else is secondary and should be quieter: shorter, smaller, or a fade rather than a move.
Sequence by cause and effect. If opening a panel causes its content to load, the panel moves first and the content follows. A delay of 40 to 80 milliseconds between the primary and secondary motions is enough for the eye to register the order without the sequence feeling slow. Custom properties make delays systematic: --motion-delay-secondary: 60ms on the root, referenced by every secondary animation.
Share direction. Elements that move as part of the same change should move in compatible directions. A drawer sliding in from the right whose contents fade upward reads as two unrelated events; contents that shift slightly from the right, following the drawer, read as one.
Keep the total short. Choreography adds delays, and delays add up. Budget the whole sequence — primary motion plus the last secondary motion's delay and duration — to stay under about half a second for routine interactions. If it does not fit, reduce the number of animated elements rather than speeding each one up; fewer, clearer motions beat many rushed ones.
Let reduced motion collapse the sequence. When motion is reduced, delays become pointless waiting. The token approach handles this naturally if delays are tokens too: set them to zero in the reduced-motion media query along with the movement durations.
Integration With Adjacent CSS
Custom properties and @property. Easing tokens are ordinary custom properties, but values that should themselves animate — a colour, an angle, a length — need registration with @property so the browser can interpolate them. The CSS custom properties architecture section covers where tokens live and how registered properties behave.
Container queries. Duration can depend on the space a component has, since larger containers mean longer travel. Container query units inside clamp() produce durations that grow with the component and stay bounded.
Scroll-driven animations. When progress comes from scroll position, easing works differently: the timing function still applies, but it warps the relationship between scroll distance and motion. Most scroll-linked effects use linear and shape the motion with keyframe offsets and animation-range.
View transitions. The pseudo-elements of a view transition animate with ordinary animation properties, so the same easing and duration tokens apply to page-level transitions. Using them keeps page transitions consistent with component motion.
Cascade layers. A dedicated motion layer for easing tokens and reduced-motion overrides keeps motion policy in one place and guarantees the overrides win over component styles regardless of specificity.
Performance and Accessibility Notes
Easing does not change cost. A spring or a Bezier curve costs the same per frame as linear; the work is in interpolating and rendering the property. Choose curves for feel, and choose properties for performance — transform, translate, scale and opacity stay on the compositor, as covered in Optimizing CSS Animations for 60fps.
Overshoot is extra motion. Springs, bounces and overshoot add movement beyond what the state change requires, which is precisely what users with vestibular disorders ask to avoid. Reduced-motion variants should drop them entirely.
Durations affect usability, not just feel. Long entrances delay access to content; long exits keep stale content on screen. Routine transitions should stay under about 400 milliseconds.
Loops need pause mechanisms. Anything that animates automatically for more than five seconds needs a way to pause it under WCAG 2.2.2, regardless of how gentle its easing is.
Motion should never be the only signal. A state conveyed only by movement — a shake to indicate an error, a bounce to mark a new item — is lost on users who have motion disabled and on screen-reader users. Pair every meaningful motion with a persistent visual change such as colour, text or an icon, and with an accessible announcement where the change matters.
DevTools Debugging Workflow
- Edit curves live. In Chrome, Edge and Firefox, click the curve icon beside any timing function in the Styles pane. The editor shows the curve, lets you drag control points, and replays a preview. Chromium's editor also offers presets grouped by family.
- Slow everything down. The Animations panel in Chromium and Firefox can play all animations at 25% or 10% speed. Many easing problems — a curve that starts too slowly, an overshoot that is too large — are invisible at full speed and obvious at a quarter.
- Inspect
linear()points. Chromium's easing editor renderslinear()values as their polyline, which makes it easy to spot a generator mistake such as a point out of order. - Check tokens resolve. In the Computed pane, a transition using
var(--motion-enter)shows the resolved duration and curve. If it shows0s ease, a token is undefined. - Emulate reduced motion. Use the Rendering drawer in Chromium, or Firefox's accessibility settings, to emulate
prefers-reduced-motion: reduceand confirm the whole system switches.
Browser Compatibility
| Feature | Chrome / Edge | Firefox | Safari |
|---|---|---|---|
transition and cubic-bezier() | 26+ / 12+ | 16+ | 9+ |
@keyframes and per-keyframe timing | 43+ / 12+ | 16+ | 9+ |
linear() easing | 113+ | 112+ | 17.2+ |
steps() with jump-* positions | Supported in current versions | Supported in current versions | Supported in current versions |
prefers-reduced-motion | 74+ / 79+ | 63+ | 10.1+ |
:has() (segmented control example) | 105+ | 121+ | 15.4+ |
For linear(), declare a cubic-bezier() fallback before the linear() value; older engines keep the fallback.
Common Pitfalls
| Pitfall | Cause | Resolution |
|---|---|---|
| Everything feels sluggish | ease or ease-in on entrances | Use a decelerating curve for arrivals |
| Menu lingers after closing | Exit uses the same duration as entry | Shorter exit via the base state's transition |
| Overshoot invisible on fades | opacity is clamped to 0–1 | Overshoot translate or scale instead |
| Sprite slides between frames | Step count does not match distance | steps(distance ÷ frame size) |
| Scroll animation lags the scroll | Non-linear easing on a scroll timeline | Use linear; shape with keyframe offsets |
FAQ
What is the difference between easing and duration? Duration is how long the motion takes. Easing is how progress is distributed across that time: whether it starts fast or slow, whether it overshoots, whether it moves in steps. Two animations with the same duration can feel completely different because of their easing.
How many easing curves should a design system have? Usually three to five: a standard ease-out for most entrances and state changes, an ease-in for exits, a symmetric ease-in-out for elements that move between two on-screen positions, and optionally one expressive curve with overshoot or a spring for emphasis.
Why does ease feel different from ease-out?
The ease keyword is cubic-bezier(0.25, 0.1, 0.25, 1), which starts slightly slowly, accelerates, then decelerates. ease-out starts at full speed. For entrances, ease-out feels more responsive because motion is visible from the first frame.
Should scroll-driven animations use easing?
Usually linear. A scroll timeline maps scroll position to progress, and a non-linear timing function distorts that mapping so the element moves at a different rate from the scroll. Shape the motion with keyframe offsets instead.
How should motion design handle reduced-motion preferences? Treat reduced motion as a first-class variant of the design, not an afterthought. Movement, scaling, parallax and bounce are removed or replaced with short fades, and duration tokens let the whole system switch with one media query.
Related
- Choosing Animation Durations — the duration scale in depth.
- Overshoot and Anticipation With cubic-bezier — expressive single curves.
- Spring and Bounce Easing With linear() — physical motion in pure CSS.
- steps() Easing for Sprites and Typing — frame-by-frame effects.
- Entry vs Exit Easing — asymmetric transitions.
- CSS Transition Timing Functions — the built-in curves.
- Fluid Space Scale With clamp() — the spatial scale motion tokens can mirror.
Related articles
More pages in the same section.