CSS-animations
You can build a surprising amount of motion with pure CSS and zero JavaScript. Change a property, and the browser can animate its way from the old value to the new one on its own.
Where JavaScript earns its keep is control: a few lines to trigger, pause, chain, or react to an animation the browser is running. So the two work as a team. CSS does the smooth pixel-pushing, JavaScript decides when and why.
CSS transitions
The idea behind a transition is small. You tell the browser which property to watch and how long the change should take. From then on, whenever that property’s value changes, the browser fills in every frame between the two states for you.
You never write the in-between frames. You just flip the property, and the fluid motion happens.
Here’s a rule that animates any change to background-color over three seconds:
.animated {
transition-property: background-color;
transition-duration: 3s;
}
Give an element the .animated class, and from that point on every background-color change fades across three seconds instead of snapping instantly.
background: white
background: red
Click the button below to watch it happen. The script only sets the color once; the three-second fade is entirely CSS:
<button id="color">Click me</button>
<style>
#color {
transition-property: background-color;
transition-duration: 3s;
}
</style>
<script>
color.onclick = function() {
this.style.backgroundColor = 'red';
};
</script>
A transition is described by four properties:
transition-propertytransition-durationtransition-timing-functiontransition-delay
We’ll take them one at a time. For now, know that the shorthand transition lets you declare all four together in the order property duration timing-function delay, and you can list several properties at once by separating them with commas.
This button animates color and font-size together, each with its own duration:
<button id="growing">Click me</button>
<style>
#growing {
transition: font-size 3s, color 2s;
}
</style>
<script>
growing.onclick = function() {
this.style.fontSize = '36px';
this.style.color = 'red';
};
</script>
Now let’s walk through the four properties.
transition-property
transition-property is the list of properties you want animated: left, margin-left, height, color, and so on. Write all to animate everything that changes.
Not every property can be animated, but most of the ones you reach for day to day are animatable. A property is animatable when the browser knows how to compute intermediate values between two states (a number, a color, a length). Something like display: none → display: block has no in-between, so it can’t tween.
transition-duration
transition-duration is how long the animation runs, written in CSS time format: seconds with s or milliseconds with ms. So 0.5s and 500ms mean the same thing.
transition-delay
transition-delay sets a wait before the animation begins. With transition-delay: 1s and transition-duration: 2s, the property changes, one second passes, and then the two-second animation plays.
Negative values behave differently, and usefully. A negative delay starts the animation right away, but jumps into it as if that much time had already elapsed. With transition-delay: -1s and transition-duration: 2s, the animation begins at its halfway point and finishes one second later.
Here a stripe of numbers slides from 0 to 9 using a CSS transform:
The transform is what animates:
#stripe.animate {
transform: translate(-90%);
transition-property: transform;
transition-duration: 9s;
}
JavaScript kicks it off by adding the .animate class, which is what changes transform and triggers the transition:
stripe.classList.add('animate');
A negative delay lets you start from anywhere in the middle. Click a digit below and the animation begins at the current second:
One extra line does it. It reads the current second, wraps it into the 0..9 range, and uses that as a negative delay so the stripe jumps straight to the matching digit:
stripe.onclick = function() {
let sec = new Date().getSeconds() % 10;
// for instance, -3s here starts the animation from the 3rd second
stripe.style.transitionDelay = '-' + sec + 's';
stripe.classList.add('animate');
};
transition-timing-function
The timing function controls the pacing of the animation across its duration. Does it start slow and finish fast? Race off and coast to a stop? Move at a constant clip? That’s what this property decides.
It looks intimidating the first time you meet it, then turns out to be manageable once you spend a few minutes with it.
It accepts two kinds of value: a Bezier curve or a set of steps. We’ll start with curves, since they cover most cases.
Bezier curve
You can set the timing as a Bezier curve defined by four control points, with a couple of fixed constraints:
- The first control point is always
(0,0). - The last control point is always
(1,1). - For the two points in between,
xmust stay within0..1, whileycan be any value at all.
The CSS syntax is cubic-bezier(x2, y2, x3, y3). You only supply the 2nd and 3rd points, because the 1st is pinned at (0,0) and the 4th at (1,1).
Read the curve like a graph of progress over time:
- The
xaxis is time:0is the start,1is the end oftransition-duration. - The
yaxis is completion:0is the property’s starting value,1is its final value.
The plainest case is uniform speed: the property moves at the same rate the whole way. That’s cubic-bezier(0, 0, 1, 1), which draws as a straight diagonal line. As time (x) advances, completion (y) climbs at exactly the same rate.
The car below crosses at a constant speed (click it):
The transition is built on that straight-line curve:
.car {
left: 0;
transition: left 5s cubic-bezier(0, 0, 1, 1);
/* clicking the car sets left to 450px, which triggers the animation */
}
To make the car decelerate, bend the curve. Try cubic-bezier(0.0, 0.5, 0.5, 1.0):
The curve shoots up early, then flattens. Steep means fast, so the car starts quick and eases off toward the end.
.car {
left: 0;
transition: left 5s cubic-bezier(0, .5, .5, 1);
/* clicking the car sets left to 450px, which triggers the animation */
}
You don’t have to hand-write curves for common cases. CSS ships named ones: linear, ease, ease-in, ease-out, and ease-in-out.
linear is just shorthand for cubic-bezier(0, 0, 1, 1) — the straight line above.
The rest map to these curves:
ease* |
ease-in |
ease-out |
ease-in-out |
|---|---|---|---|
(0.25, 0.1, 0.25, 1.0) |
(0.42, 0, 1.0, 1.0) |
(0, 0, 0.58, 1.0) |
(0.42, 0, 0.58, 1.0) |
* — when you don’t specify a timing function, ease is the default.
So the decelerating car could just use ease-out:
.car {
left: 0;
transition: left 5s ease-out;
/* same idea as transition: left 5s cubic-bezier(0, .5, .5, 1); */
}
The result is close but not identical, since the control points differ.
A Bezier curve can push the animation past its own range.
Because the intermediate y values are unbounded, the curve can dip below 0 or rise above 1. When it does, the animated property overshoots or backtracks beyond its start and end values.
Take this:
.car {
left: 100px;
transition: left 5s cubic-bezier(.5, -1, .5, 2);
/* clicking the car sets left to 450px */
}
left is supposed to travel from 100px to 400px. Click the car and you’ll instead see:
- First it rolls backward, with
leftdropping below100px. - Then forward, slightly past
400px. - Then it settles back to
400px.
The graph explains it plainly:
The 2nd point’s y sits below zero and the 3rd point’s y climbs above one, so the curve leaves the “normal” 0..1 band. Since y is completion, y < 0 means the property moves below its starting left, and y > 1 means it goes past the final left.
This one is gentle. Push those y values to -99 and 99 and the car would fling far out of bounds before returning.
How do you design a curve for a real task? Use a tool.
- The site https://cubic-bezier.com lets you drag control points and preview the motion.
- Browser dev tools have a built-in curve editor:
- Open dev tools with F12 (Mac: Cmd+Opt+I).
- Go to the
Elementstab and find theStylespanel on the right. - Any CSS value containing
cubic-beziershows a small icon beside it. - Click that icon to edit the curve visually.
Steps
Instead of a smooth curve, steps(number of steps[, start/end]) breaks the transition into discrete jumps. The property snaps from one value to the next, with no in-between frames.
A digit counter shows this off well.
Here’s a plain list of digits, no animation, just the raw material:
In the markup, a strip of digits lives inside a fixed-width <div id="digit">:
<div id="digit">
<div id="stripe">0123456789</div>
</div>
The #digit div has a fixed width and a border, so it acts like a little red window. We hide everything outside it with overflow: hidden, then shift #stripe left one digit at a time. Ten digits, so nine moves to get from 0 to 9:
#stripe.animate {
transform: translate(-90%);
transition: transform 9s steps(9, start);
}
The first argument, 9, is the number of steps: the -90% shift splits into nine chunks of 10% each, and the 9s duration splits into nine one-second slices. So one digit per second.
The second argument is start or end, and it decides when within each slice the jump happens.
start means the jump fires at the beginning of each slice — the first move is immediate.
Click and the digit flips to 1 right away, then advances at the top of every following second:
0s—-10%(first jump happens immediately)1s—-20%- …
8s—-90%- (the final second just holds on the last value)
With end, the jump fires at the end of each slice instead. So steps(9, end) waits a beat before the first move:
0s—0(nothing changes during the first second)1s—-10%(first jump at the end of the 1st second)2s—-20%- …
9s—-90%
There are two shorthands for single-step transitions:
step-startequalssteps(1, start): one step, applied immediately. It jumps to the final value at once, so it looks like no animation ran.step-endequalssteps(1, end): one step applied at the very end oftransition-duration. The value snaps at the finish.
These are rarely used, since they’re really just an instant change rather than motion. Good to recognize them, though.
Event: “transitionend”
When a transition finishes, the browser fires a transitionend event on the element. This is your hook for doing something after the motion completes — clean up, start the next phase, or chain animations back to back.
The paper plane below flies right, then back, going a little farther each round trip when clicked:
The go function drives it. It runs once to start, then reruns on every transitionend, flipping direction each time based on whether times is odd or even:
plane.onclick = function() {
//...
let times = 1;
function go() {
if (times % 2) {
// fly to the right
plane.classList.remove('back');
plane.style.marginLeft = 100 * times + 200 + 'px';
} else {
// fly to the left
plane.classList.add('back');
plane.style.marginLeft = 100 * times - 200 + 'px';
}
}
go();
plane.addEventListener('transitionend', function() {
times++;
go();
});
};
The event object carries a couple of transition-specific fields:
event.propertyNameThe name of the property that just finished animating. Useful when several properties are transitioning at once and you need to tell them apart.
event.elapsedTimeHow long the animation actually ran, in seconds, not counting
transition-delay.
Keyframes
Transitions animate a single change from one value to another. When you want a multi-stage animation — several waypoints, looping, direction changes — you reach for the @keyframes rule.
@keyframes names an animation and lists its stages: what to animate, and at which points along the timeline. Then the animation property attaches that named animation to an element and sets how it plays.
Here’s a box that slides left and right forever:
<div class="progress"></div>
<style>
@keyframes slide-track { /* name it: "slide-track" */
from { left: 0px; } /* animate from left: 0px */
to { left: calc(100% - 50px); } /* animate to left: 100%-50px */
}
.progress {
animation: slide-track 3s infinite alternate;
/* apply the "slide-track" animation to the element
duration 3 seconds
repeat count: infinite
alternate direction on each repeat
*/
position: relative;
border: 2px solid green;
width: 50px;
height: 20px;
background: lime;
}
</style>
left: 0
left: 100%-50px
Here it is running. The bar bounces between the two waypoints forever, reversing on every pass thanks to alternate:
There’s a full CSS animations specification and no shortage of articles if you want the complete @keyframes reference.
Realistically you won’t reach for @keyframes all the time, unless your site is meant to keep things constantly in motion.
Performance
Most CSS properties can be animated because most are numeric under the hood — width, color, font-size all boil down to numbers. To animate one, the browser steps that number frame by frame, which reads as smooth motion.
But smoothness isn’t guaranteed, because different properties cost different amounts to change. To understand why, look at what the browser does on every style change. It runs up to three stages to draw the new look:
- Layout — recompute the geometry and position of affected elements (sizes, where things sit).
- Paint — fill in how each element looks in its place: colors, backgrounds, borders, text.
- Composite — assemble the painted pieces into the final on-screen pixels, applying CSS transforms.
most expensive
medium
cheapest
During an animation this pipeline reruns every frame. Properties that never touch geometry — color, for instance — can skip Layout entirely: change a color and the browser goes straight to Paint, then Composite. A few properties skip even Paint. There’s a searchable list of which property triggers which stage at https://csstriggers.com.
Layout and Paint aren’t free, especially on a busy page with deep, complex markup. On a lot of devices the cost shows up as visible stutter — the “janky” animation everyone’s seen. The rule of thumb: the more stages a property forces, the more likely it drops frames.
That makes transform the standout choice, because:
- A transform reshapes the element’s box as a whole — rotate, flip, stretch, shift.
- A transform never disturbs neighboring elements.
So the browser can apply transform on top of an already-computed Layout and Paint, purely in the Composite stage. It figures out sizes and positions once, paints the pixels once, then just repositions the finished box.
Because of that, animating transform skips Layout and Paint completely. Better still, the browser can hand transforms off to the graphics accelerator (a dedicated chip on the CPU or GPU), which makes them very cheap to animate.
And transform is capable. With it you can rotate, flip, scale, translate, and more. So instead of animating left or margin-left, use transform: translateX(...); instead of animating width/height to grow something, use transform: scale(...).
opacity is the other cheap one — it never triggers Layout (and in Mozilla’s Gecko engine it skips Paint too). It’s the go-to for show/hide and fade effects.
Here, clicking #balloon adds a class that sets transform: translateX(300px) and opacity: 0, so the balloon drifts right and fades out at the same time:
<img src="https://example.com/clipart/balloon.png" id="balloon">
<style>
#balloon {
cursor: pointer;
transition: transform 2s ease-in-out, opacity 2s ease-in-out;
}
.move {
transform: translateX(300px);
opacity: 0;
}
</style>
<script>
balloon.onclick = () => balloon.classList.add('move');
</script>
A self-contained version you can run: clicking the balloon adds .move, drifting it right on transform while fading it out on opacity — both on the cheap Composite path. Click again to send it back.
And a richer @keyframes version that combines translate, rotate, scale, and fade across three waypoints:
<h2 onclick="this.classList.toggle('animated')">click me to start / stop</h2>
<style>
.animated {
animation: pop-and-vanish 1.8s infinite;
width: fit-content;
}
@keyframes pop-and-vanish {
0% {
transform: translateY(-60px) rotateX(0.7turn);
opacity: 0;
}
50% {
transform: none;
opacity: 1;
}
100% {
transform: translateX(230px) rotateZ(90deg) scale(0.5);
opacity: 0;
}
}
</style>
Click the heading to toggle the loop on and off:
Summary
CSS animations smoothly (or step-by-step) animate changes to one or more CSS properties, and they handle the bulk of everyday animation work. JavaScript animations are the subject of the next chapter, for the cases CSS can’t reach.
Where CSS animations stop and JavaScript starts:
The early examples here animate font-size, left, width, height, and friends for clarity. In production, prefer transform: scale() and transform: translate() for smoother, cheaper motion.
Most animations you’ll ever need fit within the CSS techniques above. And because transitionend lets JavaScript run right after an animation finishes, CSS motion slots cleanly into your code. The next chapter picks up where CSS leaves off, with JavaScript-driven animation for the trickier cases.