Two animations can move the same element the same distance in the same number of milliseconds, and one looks right while the other looks like a spreadsheet moving a cell. The difference is the timing function, the curve that says how the speed changes over the duration. Nothing physical moves at a constant speed; things start, pick up, and settle. An interface that ignores that looks wrong to people who could not tell you why.
My own position on interface motion is a tester's position: most of it is too long, and every animation is a wait. A 400 millisecond modal is 400 milliseconds on every test step that opens it, thousands of times a release, and it is the same wait for a user who opens it fifty times a day. On ToolForte, which I built and maintain alone, almost all the motion is colour fades of 150 to 200 milliseconds and nothing slides anywhere. So this post is the curves and when to use them, the durations I would defend, and the properties that make animation stutter.
Five keywords, and ease-out wins most of the time
CSS ships five named curves, and they cover most of what an interface needs.
linear is constant speed. It reads as mechanical, which is right for a progress bar or a spinner and wrong for anything that is supposed to feel like it arrived. ease is the default when you specify nothing: a slow start, a fast middle, a slow end. It is fine, and slightly sluggish off the mark for something the user just clicked. ease-in starts slowly and accelerates, which suggests the element is leaving and gathering speed as it goes; use it for exits. ease-out starts fast and slows to a stop, which is what an arriving element should do, and what a click should get as feedback: instant movement, settled finish. ease-in-out is symmetric, for something that stays on screen and moves from one place to another.
If you set one rule for a codebase, make it ease-out on entrances and interactions, ease-in on exits, and nothing else without a reason. Most of the motion I see that feels heavy is ease or ease-in-out on a hover state, where the slow start reads as lag.

Custom curves worth copying
When the keywords are not enough, cubic-bezier takes four numbers: two control points on a graph where the horizontal axis is time from 0 to 1 and the vertical axis is progress from 0 to 1.
`css
transition-timing-function: cubic-bezier(0.25, 0.1, 0.25, 1.0);
`
The form is cubic-bezier(x1, y1, x2, y2). The first point shapes the start, the second the end. A handful of values I would keep in a snippet file:
A firm ease-out, Material's decelerate curve, for elements arriving:
`css
cubic-bezier(0.0, 0.0, 0.2, 1.0)
`
Material's standard curve, for elements moving on screen:
`css
cubic-bezier(0.4, 0.0, 0.2, 1.0)
`
A cubic ease-out with a longer settle, the easeOutCubic from easings.net:
`css
cubic-bezier(0.215, 0.61, 0.355, 1.0)
`
A soft overshoot, easeOutBack, where the element passes its target and comes back:
`css
cubic-bezier(0.34, 1.56, 0.64, 1.0)
`
The vertical values may go outside 0 to 1, which is how the overshoot works: progress briefly exceeds one hundred percent and returns. It reads as springy. Use it on one thing per screen at most; on everything it reads as a toy.
When the keywords are not enough, `cubic-bezier` takes four numbers: two control points on a graph where the horizontal axis is time from 0 to 1 and the vertical axis is progress from 0 to 1.
Duration: shorter than you think
The curve sets the shape; the duration sets whether anyone waits. The ranges the large design systems agree on are close to what I would choose myself.
Under about 100 milliseconds for micro-interactions: button states, hover, a toggle. Long enough to be seen, short enough to be felt as immediate. Between 150 and 300 for ordinary transitions: a menu opening, a modal appearing, a sidebar expanding. Up to 500 for large movements where the eye has to travel, a page transition or a big element changing place. Beyond that is decoration, and nothing that responds to a click should ever be there.
Scale duration with distance. A tooltip that moves ten pixels wants about 100 milliseconds; a sidebar sliding three hundred pixels wants 250 to 300; a full-viewport slide wants 400 to 500. The failure I see most is the reverse: a small element given a long duration because it looked nice in the design tool, at a scale where the designer watched it once. Users watch it hundreds of times.
Spinners are the exception to all of it: linear, infinite, and no easing, because a spinner is a signal that something is still happening, not a movement to a destination.
Stagger a list, then cap it
When several elements enter at once, a short delay between them reads as intentional rather than as a page loading in a lump. The usual pattern is a cascade on a list:
`css
.item {
opacity: 0;
transform: translateY(20px);
animation: fadeUp 300ms ease-out forwards;
}
.item:nth-child(1) { animation-delay: 0ms; }
.item:nth-child(2) { animation-delay: 50ms; }
.item:nth-child(3) { animation-delay: 100ms; }
.item:nth-child(4) { animation-delay: 150ms; }
@keyframes fadeUp {
to { opacity: 1; transform: translateY(0); }
}
`
Thirty to eighty milliseconds between items keeps the sequence connected while each entrance is still visible on its own. Cap the stagger at the first five to eight items. Past that the total runs long and the later items sit invisible for a second or more, which is the one outcome worse than no animation: content that exists and cannot be seen. Items beyond the cap should appear at once, or at a fixed maximum delay.
The CSS Animation Generator writes the keyframes and the easing for a pattern like this so you can adjust the numbers and copy the result rather than typing the boilerplate.

Animate transform and opacity, and almost nothing else
A smooth animation renders a frame every 16.7 milliseconds at 60 frames per second, and faster still on the 120 hertz screens most phones now have. If a frame triggers layout or paint work that takes longer than its slot, frames drop and the motion stutters.
Two properties are safe: transform (translate, rotate, scale, skew) and opacity. The browser's compositor handles them off the main thread, so they stay smooth even while JavaScript is busy. filter is usually composited too, but not everywhere, so I would not rely on it.
Properties that cause layout, and should not be animated, are width, height, padding, margin, top, left and their siblings, font-size and border-width. Properties that cause paint, cheaper but not free, are background-color, color, box-shadow and border-color. The practical rewrite is mechanical: left: 0 to left: 100px becomes transform: translateX(100px); width: 0 to width: 200px becomes transform: scaleX(1) with a transform-origin.
will-change tells the browser an element is about to move so it can prepare a layer:
`css
.about-to-animate {
will-change: transform, opacity;
}
`
Use it on the element that is about to animate, add it just before and remove it after. On many elements at once it costs graphics memory and makes things slower, which is the opposite of what people apply it for.
When the keyframes have grown, run the stylesheet through the CSS Minifier before shipping; keyframe blocks with many steps are mostly whitespace.
What linear() adds, and where springs stand
A cubic-bezier has two control points, which means it cannot bounce more than once or describe anything with several turns. The linear() function, supported in every current browser, takes as many points as you like and interpolates straight lines between them:
`css
transition-timing-function: linear(
0, 0.006, 0.025, 0.056, 0.1, 0.157, 0.225, 0.306,
0.4, 0.506, 0.625, 0.756, 0.9, 1.056, 1.125,
1.106, 1.05, 1.006, 0.981, 0.975, 0.981, 1
);
`
That list is a sampled spring bounce, the kind of curve that previously needed a JavaScript library. Tools exist that sample a spring and emit the linear() for it.
A true spring is a simulation, mass, stiffness and damping, rather than a fixed curve, and its practical advantage is interruption. If a user triggers a new animation while the old one is running, a spring carries the current velocity into the new motion; a bezier restarts from zero and jumps. Framer Motion and React Spring give you that in JavaScript. A CSS spring() easing has been proposed and has not shipped anywhere as of this update, so do not plan on it.
For most production CSS the keywords and a couple of bezier values cover the need. I would learn linear() for the one or two places where a bounce is the point, and leave springs to the JavaScript layer until there is a native one.
A cubic-bezier has two control points, which means it cannot bounce more than once or describe anything with several turns.
FAQ
Which timing function should be the default?
ease-out. It responds instantly to the click and settles at the end. The built-in ease has a slower start that reads as lag on interactive elements.
Why do animations feel slow on phones?
Partly expectation: people expect a phone to respond faster than a desktop, so I would trim durations by a fifth to a third on small screens. Partly hardware: mobile graphics are more sensitive to paint work, so the transform-and-opacity rule matters more there, not less.
Should I honour prefers-reduced-motion?
Yes, and not as an afterthought. Remove decorative motion entirely and replace functional motion, a sidebar opening, a modal appearing, with an instant change or a short opacity fade. The blanket approach is crude and works:
`css
@media (prefers-reduced-motion: reduce) {
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
}
`
Can CSS animation hold 60 frames per second?
Yes, if it animates only transform and opacity, avoids layout properties, uses will-change sparingly, and is tested on a slow device rather than on the developer's machine. The compositor runs those two properties independently of the main thread, so even heavy script does not make them stutter.
JSONPath: Query Nested JSON Without Counting Braces
JSONPath syntax as standardised in RFC 9535, how I find a field in a 2,000-line API response, jq one-liners, three ways to flatten to CSV, and the six JSON mistakes that cost the most time.
Unix Timestamps: Seconds Since 1970, and the Three Ways They Go Wrong in a Log File
What a Unix timestamp is, why the epoch is 1970, seconds against milliseconds, why every timestamp is UTC, the conversion in four languages done correctly, and a five-step order for debugging dates that come out wrong. From someone who lines up supplier logs for a living.
Mesh Gradients: How to Build One That Does Not Look Like a Screensaver
What a mesh gradient is, how to write one as layered CSS radial-gradients, which generators are worth opening, and the rules that keep text readable on top of it.
CSS Animations and Keyframes: Transitions, Spinners, Easing and the Three Defects I Keep Logging
When a transition is enough and when you need keyframes, a spinner in five lines, ease-out in and ease-in out, hover under 200 ms, which properties are cheap to animate, and reduced motion as a requirement.
