CSS Module 16 – Transitions, animations, scroll and view transitions
Keyframe animations and smooth motion
How @keyframes animations run: duration, iterations, direction and fill modes, combining them, what is cheap to animate, and reduced motion.
What you will learn
- Define @keyframes and set an animation's duration, iteration count, direction, fill mode and play state
- Predict where an animation is at any moment from its delay, iterations, direction and fill mode
- Combine two animations on one property with animation-composition
- Choose properties that animate without new layout or paint, and measure the difference
- Replace or remove motion for people who ask for less, and let people pause animations that run on
Before you start
What you’ll learn · Before you start
What you will learn
- Define @keyframes and set an animation's duration, iteration count, direction, fill mode and play state
- Predict where an animation is at any moment from its delay, iterations, direction and fill mode
- Combine two animations on one property with animation-composition
- Choose properties that animate without new layout or paint, and measure the difference
- Replace or remove motion for people who ask for less, and let people pause animations that run on
Before you start
On this page
A keyframe animation moves an element through a sequence of styles that you write in advance, on its own clock.
Where a transition only blends between an old value and a new one when something changes, an animation can have any
number of steps, can start as soon as the page loads, can repeat or run forever, and can play backwards. You describe
the steps once in a @keyframes rule, then attach it to elements with the animation properties: its name, how long
one cycle lasts, its easing, a delay, how many times it repeats, which way it runs and what it shows before it starts
and after it ends. Each of those settings is measured here by pausing real animations in three engines (Chromium
153, Firefox 155, WebKit 26.6), before the two questions that decide whether an animation helps or hurts: what it
costs the browser on every frame, and whether people who ask their system for less motion get less.
@keyframes and the animation properties
A @keyframes rule has a name and a list of keyframes. Each keyframe is a selector, from (0 %), to (100 %) or
percentages, with the declarations the element should have at that point. Several percentages can share one block,
which is how this bell shakes twice and then rests for most of each cycle:
/* A bell that shakes twice and then rests, once per new notification, and a pulsing skeleton row. */
@keyframes ring {
0%, 40%, 100% { rotate: 0deg; }
5%, 25% { rotate: 14deg; }
15%, 35% { rotate: -14deg; }
}
.bell.has-new {
transform-origin: 50% 0; /* swing from the top, where a real bell hangs */
animation: ring 1600ms ease-in-out 2; /* two shakes, a rest, twice: 3.2 s in all */
}
@keyframes pulse {
from { opacity: 1; }
to { opacity: 0.45; }
}
.skeleton {
background: #e5e7eb;
animation: pulse 900ms ease-in-out infinite alternate;
}
/* Less motion: no swinging at all; the skeleton keeps a slow fade, which does not move. */
@media (prefers-reduced-motion: reduce) {
.bell.has-new { animation: none; }
.skeleton { animation-duration: 2400ms; }
}
/* A switch for people who want everything still (WCAG 2.2 success criterion 2.2.2). */
.motion-paused .skeleton { animation-play-state: paused; } The animation shorthand sets animation-name, -duration, -timing-function, -delay, -iteration-count,
-direction, -fill-mode and -play-state in one declaration; set newer properties such as
animation-composition in their own declarations after it. As with transitions, the first time is the duration and
the second the delay. Keywords are read before the name, so animation: 3s none backwards means an animation
named backwards with no fill mode; never name keyframes after keywords such as ease, infinite or forwards.
When two @keyframes rules share a name, the last one in the stylesheet wins and the other is ignored entirely.
Three rules about keyframes catch people out:
- Missing ends are filled in. Without a
fromortokeyframe, the browser builds one from the element’s own computed value, so@keyframes nudge { to { translate: 20px; } }starts wherever the element already is. - The easing applies between keyframes.
animation-timing-functionshapes each interval separately, so withease-in-outthe bell slows down at every turn, not once over the whole cycle. A keyframe can set its ownanimation-timing-functionfor the interval that follows it. !importantis ignored inside keyframes. The specification makes such declarations invalid. On the composition page further down, afromkeyframe ofopacity: 0.2 !importantwas dropped in all three engines, and the animation started from the element’s own opacity, 0.6.
This program samples the bell the way the specification describes, every 40 ms of the first cycle:
// The bell's rotation while it rings, worked out the way CSS samples keyframes: find the two keyframes around the
// current point of the iteration, apply the easing to the progress between them (ease-in-out here, because
// animation-timing-function applies to each keyframe interval), then interpolate the angle.
const keyframes = [[0, 0], [0.05, 14], [0.15, -14], [0.25, 14], [0.35, -14], [0.4, 0], [1, 0]]; // [offset, degrees]
const duration = 1600;
function cubicBezier(x1, y1, x2, y2) {
const coord = (a, b, s) => 3 * a * (1 - s) ** 2 * s + 3 * b * (1 - s) * s ** 2 + s ** 3;
return (t) => {
let lo = 0, hi = 1, s = t;
for (let i = 0; i < 60; i++) {
if (coord(x1, x2, s) < t) lo = s; else hi = s;
s = (lo + hi) / 2;
}
return coord(y1, y2, s);
};
}
const easeInOut = cubicBezier(0.42, 0, 0.58, 1);
function angleAt(ms) {
const p = (ms % duration) / duration; // progress through the current iteration
let i = keyframes.findLastIndex(([offset]) => offset <= p);
if (i === keyframes.length - 1) i -= 1;
const [[o1, a1], [o2, a2]] = [keyframes[i], keyframes[i + 1]];
const local = easeInOut((p - o1) / (o2 - o1)); // eased progress between the two keyframes
const angle = a1 + (a2 - a1) * local;
return Math.abs(angle) < 0.05 ? 0 : angle; // no "-0.0" for a bell at rest
}
for (let ms = 0; ms <= 640; ms += 40) {
const a = angleAt(ms);
const bar = a >= 0 ? ' '.repeat(14) + '#'.repeat(Math.round(a)) : ' '.repeat(14 + Math.round(a)) + '#'.repeat(-Math.round(a));
console.log(`${String(ms).padStart(4)} ms ${a.toFixed(1).padStart(6)}° ${bar}`.trimEnd());
}
console.log(`From 640 ms to ${duration} ms the bell rests at ${angleAt(1000).toFixed(1)}°, then rings once more.`); Output
0 ms 0.0° 40 ms 7.0° ####### 80 ms 14.0° ############## 120 ms 10.4° ########## 160 ms 0.0° 200 ms -10.4° ########## 240 ms -14.0° ############## 280 ms -10.4° ########## 320 ms 0.0° 360 ms 10.4° ########## 400 ms 14.0° ############## 440 ms 10.4° ########## 480 ms 0.0° 520 ms -10.4° ########## 560 ms -14.0° ############## 600 ms -7.0° ####### 640 ms 0.0° From 640 ms to 1600 ms the bell rests at 0.0°, then rings once more.
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ring.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The bell’s page reads the same angles from the browser by pausing the animation and moving its clock, and all three engines matched the program to a tenth of a degree, from 7.0° at 40 ms to −14.0° at 240 ms:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A bell and a skeleton, with and without reduced motion</title>
<link rel="stylesheet" href="bell.css">
</head>
<body>
<svg class="bell has-new" role="img" aria-label="3 new notifications" viewBox="0 0 24 24" width="48" height="48">
<path d="M12 2a6 6 0 0 0-6 6v4l-2 4h16l-2-4V8a6 6 0 0 0-6-6zm-2 16a2 2 0 0 0 4 0z" fill="currentColor"/>
</svg>
<div class="skeleton"> </div>
<button type="button" id="pause" aria-pressed="false">Pause animations</button>
<script>
// Which animations run, with what timing, when the system asks for less motion and when it doesn't.
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
for (const el of document.querySelectorAll('.bell, .skeleton')) {
const list = el.getAnimations().map((a) => {
const t = a.effect.getTiming();
return `${a.animationName} ${t.duration} ms × ${t.iterations}`;
});
console.log(`${reduce ? 'reduce' : 'no-preference'}, ${el.classList[0]}: ${list.join('; ') || 'no animation'}`);
}
// The bell's angle at fixed times, read from the paused animation.
const bell = document.querySelector('.bell');
const [ring] = bell.getAnimations();
if (ring) {
ring.pause();
const angles = [0, 40, 80, 120, 160, 200, 240, 400, 560, 640, 1000].map((ms) => {
ring.currentTime = ms;
const a = parseFloat(getComputedStyle(bell).rotate) || 0;
return `${ms} ms ${(Math.abs(a) < 0.05 ? 0 : a).toFixed(1)}°`;
});
console.log(angles.join(', '));
ring.play();
}
// The pause switch: one class on the body pauses the skeleton; pressing again resumes it.
const pause = document.getElementById('pause');
pause.addEventListener('click', () => {
const paused = document.body.classList.toggle('motion-paused');
pause.setAttribute('aria-pressed', String(paused));
});
pause.click();
const [pulse] = document.querySelector('.skeleton').getAnimations();
console.log(`after "Pause animations": the skeleton's animation is ${pulse.playState}, aria-pressed ${pause.getAttribute('aria-pressed')}`);
</script>
</body>
</html> The clock: delay, iterations, direction and fill
An animation’s clock runs through three phases, and the fill mode decides what the element shows outside the active one.
The phases of an animation, and what the fill modes show in each
Text description of the diagram
The diagram shows an animation's clock in three phases, one after the other.
- Before: the delay. With animation-fill-mode backwards or both, the element shows the first keyframe while it waits. With none or forwards, it shows its own style.
- When the delay is over, the active phase begins. It lasts the duration times the iteration count. Each iteration runs from 0% to 100% of the keyframes; with alternate, every second iteration runs back from 100% to 0%. animation-play-state: paused stops the clock where it is, and running continues from there.
- When the last iteration ends, the after phase begins. With forwards or both, the element keeps the values where the animation stopped. With none or backwards, it returns to its own style.
The page below runs one keyframe rule, translate from 20px to 100px, with every fill mode and every direction. The
element’s own translate is 0px, so 0px means the animation has no effect at that moment. The fill modes run once for
1000 ms after a 500 ms delay, sampled during the delay (250 ms), halfway (1000 ms) and after the end (2000 ms); the
directions run twice for 1000 ms, sampled in the first and the second iteration:
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Fill modes and directions, sampled at fixed times</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.bar { block-size: 8px; inline-size: 40px; margin: 4px; background: #7c3aed; translate: 0; }
@keyframes slide {
from { translate: 20px; }
to { translate: 100px; }
}
/* One iteration of 1000 ms after a 500 ms delay, with each fill mode. */
.fill { animation: slide 1000ms linear 500ms; }
#none { animation-fill-mode: none; }
#forwards { animation-fill-mode: forwards; }
#backwards { animation-fill-mode: backwards; }
#both { animation-fill-mode: both; }
/* Two iterations of 1000 ms with each direction. */
.direction { animation: slide 1000ms linear 2; }
#normal { animation-direction: normal; }
#reverse { animation-direction: reverse; }
#alternate { animation-direction: alternate; }
#alternate-reverse { animation-direction: alternate-reverse; }
</style>
</head>
<body>
<div class="bar fill" id="none"></div>
<div class="bar fill" id="forwards"></div>
<div class="bar fill" id="backwards"></div>
<div class="bar fill" id="both"></div>
<div class="bar direction" id="normal"></div>
<div class="bar direction" id="reverse"></div>
<div class="bar direction" id="alternate"></div>
<div class="bar direction" id="alternate-reverse"></div>
<script>
// Hold each animation still, move its clock to fixed times and read translate. The element's own value is 0px.
const at = (el, times) => {
const [animation] = el.getAnimations();
animation.pause();
return times.map((ms) => {
animation.currentTime = ms;
return Math.round(parseFloat(getComputedStyle(el).translate) || 0) + 'px';
});
};
for (const el of document.querySelectorAll('.fill')) {
console.log(`fill ${el.id}: ${at(el, [250, 1000, 2000]).join(', ')}`);
}
for (const el of document.querySelectorAll('.direction')) {
console.log(`direction ${el.id}: ${at(el, [250, 1250]).join(', ')}`);
}
</script>
</body>
</html> | Fill mode | In the delay | After the end |
|---|---|---|
none |
0px | 0px |
forwards |
0px | 100px |
backwards |
20px | 0px |
both |
20px | 100px |
| Direction | 250 ms | 1250 ms |
|---|---|---|
normal |
40px | 40px |
reverse |
80px | 80px |
alternate |
40px | 80px |
alternate-reverse |
80px | 40px |
All three engines logged these exact values, and halfway, at 1000 ms, all four fill modes were at 60px. backwards
matters whenever there is a delay: without it, the element
sits at its own style during the delay and then jumps to the first keyframe. forwards keeps the end state, but the
end of an alternate animation with an even iteration count is its start, so forwards then shows the from
values. Iteration counts may be fractions, 1.5 stops halfway through a second run, and a negative delay starts the
animation part of the way through, which is a simple way to offset several dots of a loader from one another.
animation-play-state: paused stops the clock where it is; setting it back to running continues from that point.
Several animations on one property: animation-composition
When two animations set the same property, the one later in the animation-name list wins, and by default an
animation replaces the element’s own value. animation-composition, Baseline widely available, changes that: add and
accumulate combine an animation’s value with the value underneath it instead of replacing it.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>animation-composition and !important in keyframes</title>
<style>
body { margin: 0; font: 16px/24px Arial, sans-serif; }
.badge { inline-size: 40px; block-size: 40px; margin: 8px; background: #b45309; }
/* A badge already moved 50px by its own style, animated by 20px more. */
@keyframes nudge { to { translate: 20px; } }
.moved { translate: 50px; animation: nudge 1000ms linear forwards paused; }
#replace { animation-composition: replace; }
#add { animation-composition: add; }
#accumulate { animation-composition: accumulate; }
/* add joins the lists; accumulate adds the numbers inside matching functions. */
@keyframes soften { to { filter: blur(4px); } }
.blurred { filter: blur(2px); animation: soften 1000ms linear forwards paused; }
#filter-replace { animation-composition: replace; }
#filter-add { animation-composition: add; }
#filter-accumulate { animation-composition: accumulate; }
/* Two animations on one property: the later one wins, unless it adds itself on top. */
@keyframes rise { from { translate: 0 0; } to { translate: 0 -30px; } }
@keyframes wobble { from { translate: 0 0; } to { translate: 10px 0; } }
.both { animation: rise 1000ms linear forwards paused, wobble 1000ms linear forwards paused; }
#together { animation-composition: replace, add; }
#together-accumulate { animation-composition: replace, accumulate; }
/* !important inside @keyframes is ignored: the declaration is dropped. */
@keyframes fade { from { opacity: 0.2 !important; } to { opacity: 1; } }
#important { opacity: 0.6; animation: fade 1000ms linear paused; }
</style>
</head>
<body>
<div class="badge moved" id="replace"></div>
<div class="badge moved" id="add"></div>
<div class="badge moved" id="accumulate"></div>
<div class="badge blurred" id="filter-replace"></div>
<div class="badge blurred" id="filter-add"></div>
<div class="badge blurred" id="filter-accumulate"></div>
<div class="badge both" id="last-wins"></div>
<div class="badge both" id="together"></div>
<div class="badge both" id="together-accumulate"></div>
<div class="badge" id="important"></div>
<script>
// Every animation is paused. Move each clock to the end (forwards keeps the last keyframe) and read the value.
const read = (el, property, ms) => {
for (const a of el.getAnimations()) a.currentTime = ms;
return getComputedStyle(el)[property];
};
for (const id of ['replace', 'add', 'accumulate']) console.log(`translate, ${id}: ${read(document.getElementById(id), 'translate', 1000)}`);
for (const id of ['filter-replace', 'filter-add', 'filter-accumulate']) console.log(`filter, ${id}: ${read(document.getElementById(id), 'filter', 1000)}`);
for (const id of ['last-wins', 'together', 'together-accumulate']) console.log(`rise and wobble, ${id}: ${read(document.getElementById(id), 'translate', 1000)}`);
console.log(`!important in a keyframe, opacity at the start: ${read(document.getElementById('important'), 'opacity', 0)}`);
</script>
</body>
</html> | What was combined | Composition | Result |
|---|---|---|
own translate: 50px, animated to 20px |
replace |
20px |
| the same | add or accumulate |
70px |
own filter: blur(2px), animated to blur(4px) |
replace |
blur(4px) |
| the same | add |
blur(2px) blur(4px) |
| the same | accumulate |
blur(6px) |
a rise to 0 -30px, then a wobble to 10px 0 |
replace |
10px |
| the same | add or accumulate |
10px -30px |
The engines agreed on every value. For a single length the two combining modes are the same sum. For a list, such as
filters or transform functions, add appends one list to the other while accumulate adds up the numbers inside
matching functions. The last row is the practical use: give the second animation add (animation-composition: replace, add) and a wobble plays on top of a rise instead of cancelling it.
Smooth motion: what each frame costs
To draw a frame, a browser recalculates styles, works out the layout of the boxes, paints them into layers and
composites the layers on screen. A property that affects geometry, such as left, width or margin, forces layout
and paint again on every frame. transform, the individual translate, rotate and scale properties, and
opacity can usually be animated by moving or fading an existing layer, without new layout or paint. These two
loaders look identical:
/* Two loaders that look the same: a dot sliding 200px and back, forever. */
.track { position: relative; inline-size: 224px; block-size: 24px; }
.dot { position: absolute; inline-size: 24px; block-size: 24px; border-radius: 50%; background: #2563eb; }
/* Moves the dot by changing left: the layout is recalculated on every frame. */
@keyframes slide-left {
from { left: 0; }
to { left: 200px; }
}
.dot.by-left { left: 0; animation: slide-left 1000ms ease-in-out infinite alternate; }
/* Moves the dot by changing translate: layout and paint stay as they are. */
@keyframes slide-translate {
from { translate: 0; }
to { translate: 200px; }
}
.dot.by-translate { animation: slide-translate 1000ms ease-in-out infinite alternate; } HTML (loader-left.html) · loader-left.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A loader animated with left</title>
<link rel="stylesheet" href="loader.css">
</head>
<body>
<div class="track" role="img" aria-label="Loading"><div class="dot by-left"></div></div>
</body>
</html> HTML (loader-translate.html) · loader-translate.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>A loader animated with translate</title>
<link rel="stylesheet" href="loader.css">
</head>
<body>
<div class="track" role="img" aria-label="Loading"><div class="dot by-translate"></div></div>
</body>
</html> Each page ran for 2 seconds in Chromium 153 with the browser’s performance trace recording, on an Apple M5-series Mac:
| In 2 seconds (121 frames) | left |
translate |
|---|---|---|
| Layout events | 121 | 0 |
| Paint events | 242 | 0 |
| Raster tasks | 121 | 0 |
| Style recalculations | 121 | 121 |
Two repeat runs gave the same counts, give or take one frame. Animating left redid layout, painting and rasterising
on every frame. Animating translate did none of them, only the style update and the compositing. On this tiny page
the layouts took about 6 ms in all, but the cost grows with the page: every layout of the left loader works on the
whole document that surrounds it, on whatever device the visitor has. Prefer transforms and opacity; animate width,
height, top, left or box-shadow only when a measurement shows the frames still fit.
will-change: transform tells the browser that a change is about to happen, so it can prepare a layer in advance.
It is a hint, not a speed-up switch: the will-change specification warns that spreading it over many elements uses a
lot of a machine’s resources and can slow a page down. Set it on the few elements that are about to move, from a
script, and remove it when they stop. It also creates a stacking context, exactly as the property it names would.
Motion people can turn off
Some people get dizzy, nauseous or distracted from movement on screen and ask their system for less of it. CSS reads
that request with @media (prefers-reduced-motion: reduce), which is Baseline widely available. The bell’s
stylesheet ends with such a block: the bell doesn’t swing at all, and the skeleton keeps its fade, slowed from 900 ms
to 2,400 ms, because fading does not move anything. Run with Playwright’s reduced-motion emulation, all three engines
reported no animation on the bell and a 2,400 ms pulse on the skeleton; without it, two rings of 1,600 ms and a
900 ms pulse.
Reduced motion is not the only rule. WCAG 2.2’s success criterion 2.2.2, Pause, Stop, Hide, is level A: moving or
blinking content that starts by itself, lasts more than five seconds and appears next to other content needs a way to
pause, stop or hide it. The bell rings for 3.2 seconds and stops, so it is fine. An infinite skeleton beside content
people are reading is not, unless it is a loading indicator shown while nothing else can be used, which the
criterion allows as essential. The last rule of the stylesheet gives the page that switch: the page’s “Pause
animations” button adds a motion-paused class to the body, which sets animation-play-state: paused on the
skeleton, and reports its state with aria-pressed. After one press, all three engines reported the skeleton’s
animation as paused.
Practise: the animation clock
Exercise · Medium · JavaScript
Work out where an animation is at any moment
Every frame, a browser turns the time since an animation started into a point between its first and last keyframe. Write animationAt(ms, timing) in clock.mjs and export it. timing has duration (milliseconds, above 0), delay (default 0, may be negative), iterations (default 1, may be a fraction or Infinity), direction ('normal', 'reverse', 'alternate' or 'alternate-reverse', default 'normal') and fill ('none', 'forwards', 'backwards' or 'both', default 'none'). Return { phase, iteration, progress }:
- phase is 'before' while ms is less than the delay, 'active' until the delay plus duration × iterations, and 'after' from then on. - Work out the active time: ms − delay in the active phase; 0 in the before phase, but only if the fill is backwards or both; duration × iterations in the after phase, but only if the fill is forwards or both. Without an active time the animation has no effect: return iteration: null, progress: null. - Divide the active time by the duration. The whole part is the iteration (counted from 0) and the fraction is the progress through it, except at the very end of the active time: a fraction of 0 there means the last iteration has just finished, so the progress is 1 and the iteration is one less. - Then apply the direction: reverse runs every iteration backwards (progress becomes 1 − progress); alternate runs even iterations forwards and odd ones backwards; alternate-reverse does the opposite.
The sample tests import animationAt from clock.mjs and run in your browser. Their numbers come from the lesson's measurements in three browser engines.
Starter code · clock.mjs
/** Where an animation is at time ms: its phase, its iteration (from 0) and its directed progress, or nulls. */
export function animationAt(ms, { duration, delay = 0, iterations = 1, direction = 'normal', fill = 'none' }) {
// Replace this line with your code.
return { phase: 'active', iteration: 0, progress: (ms - delay) / duration };
} The sample tests · clock.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { animationAt } from './clock.mjs';
const once = { duration: 1000, delay: 500 };
const twice = { duration: 1000, iterations: 2 };
test('during the delay, only backwards and both fill', () => {
assert.deepEqual(animationAt(250, { ...once, fill: 'none' }), { phase: 'before', iteration: null, progress: null });
assert.deepEqual(animationAt(250, { ...once, fill: 'forwards' }), { phase: 'before', iteration: null, progress: null });
assert.deepEqual(animationAt(250, { ...once, fill: 'backwards' }), { phase: 'before', iteration: 0, progress: 0 });
assert.deepEqual(animationAt(250, { ...once, fill: 'both' }), { phase: 'before', iteration: 0, progress: 0 });
});
test('halfway through, every fill mode gives the same point', () => {
for (const fill of ['none', 'forwards', 'backwards', 'both']) {
assert.deepEqual(animationAt(1000, { ...once, fill }), { phase: 'active', iteration: 0, progress: 0.5 });
}
});
test('after the end, only forwards and both keep the last point', () => {
assert.deepEqual(animationAt(2000, { ...once, fill: 'none' }), { phase: 'after', iteration: null, progress: null });
assert.deepEqual(animationAt(2000, { ...once, fill: 'backwards' }), { phase: 'after', iteration: null, progress: null });
assert.deepEqual(animationAt(2000, { ...once, fill: 'forwards' }), { phase: 'after', iteration: 0, progress: 1 });
assert.deepEqual(animationAt(1500, { ...once, fill: 'both' }), { phase: 'after', iteration: 0, progress: 1 });
});
test('directions over two iterations', () => {
assert.deepEqual([250, 1250].map((ms) => animationAt(ms, twice).progress), [0.25, 0.25]);
assert.deepEqual([250, 1250].map((ms) => animationAt(ms, { ...twice, direction: 'reverse' }).progress), [0.75, 0.75]);
assert.deepEqual([250, 1250].map((ms) => animationAt(ms, { ...twice, direction: 'alternate' }).progress), [0.25, 0.75]);
assert.deepEqual([250, 1250].map((ms) => animationAt(ms, { ...twice, direction: 'alternate-reverse' }).progress), [0.75, 0.25]);
assert.equal(animationAt(1250, twice).iteration, 1);
});
test('the end of an alternating animation, and fractional iterations', () => {
assert.deepEqual(animationAt(5000, { ...twice, direction: 'alternate', fill: 'forwards' }), { phase: 'after', iteration: 1, progress: 0 });
assert.deepEqual(animationAt(5000, { duration: 1000, iterations: 1.5, fill: 'forwards' }), { phase: 'after', iteration: 1, progress: 0.5 });
assert.deepEqual(animationAt(1250, { duration: 1000, iterations: 1.5 }), { phase: 'active', iteration: 1, progress: 0.25 });
});
test('negative delays start part-way, and infinite animations keep counting', () => {
assert.deepEqual(animationAt(0, { duration: 1000, delay: -250 }), { phase: 'active', iteration: 0, progress: 0.25 });
assert.deepEqual(animationAt(800, { duration: 1000, delay: -250 }), { phase: 'after', iteration: null, progress: null });
assert.deepEqual(animationAt(5250, { duration: 1000, iterations: Infinity }), { phase: 'active', iteration: 5, progress: 0.25 });
assert.deepEqual(animationAt(5250, { duration: 1000, iterations: Infinity, direction: 'alternate' }), { phase: 'active', iteration: 5, progress: 0.75 });
}); A hint
Decide the phase first, then the active time, and return early when there is none. For the end of the active time, compare the active time with duration × iterations: when they are equal and the fraction is 0, use a progress of 1 and Math.floor(…) − 1 as the iteration.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Check yourself
6 questions about this lesson. Every answer and why it is right is on the page, behind “Show the answer”. Your score stays in this browser.
Interview questions
Fresher · What is the difference between a CSS transition and a CSS animation?
A transition animates a property between two values when its computed value changes, for example when a class is
added or :hover starts to match; it needs that change to start, and it runs once from the old value to the new one.
A keyframe animation runs on its own clock as soon as animation-name names a @keyframes rule: it can have many
steps, start on page load, repeat a set number of times or forever, alternate direction, and show its first or last
keyframe before or after it runs through animation-fill-mode. Use a transition for a response to a state change,
such as a hover colour or an opening menu, and an animation for motion with its own sequence, such as a loader, a
notification shake or an entrance with several steps. Both can be inspected from a script through
element.getAnimations().
Key takeaways
@keyframeslists the styles at points from 0 % to 100 %; missing ends come from the element’s own values,!importantinside keyframes is ignored, and the easing applies to each interval between keyframes.- The
animationshorthand reads its first time as the duration and its second as the delay, and it matches keywords before names. - Fill modes decide what shows during the delay (
backwards) and after the end (forwards);bothdoes both. animation-composition: addoraccumulatecombines animations with the value underneath instead of replacing it.- Animating
leftcost a layout and two paint events per frame in Chromium;translatecost neither. Prefer transforms and opacity, and usewill-changesparingly. - Under
prefers-reduced-motion: reduce, remove movement and keep gentle fades; give anything that moves for more than five seconds a way to pause.
References
- CSS Animations Level 1 (W3C CSS Working Group)
- CSS Animations Level 2: animation-composition (W3C CSS Working Group)
- Web Animations Level 1: the timing model (W3C CSS Working Group)
- CSS Will Change Module Level 1 (W3C CSS Working Group)
- Media Queries Level 5: prefers-reduced-motion (W3C CSS Working Group)
- Understanding WCAG 2.2: Pause, Stop, Hide (W3C)
- Web Content Accessibility Guidelines (WCAG) 2.2 (W3C)
- Using CSS animations (MDN Web Docs) (Mozilla)
- animation-composition (MDN Web Docs) (Mozilla)
- will-change (MDN Web Docs) (Mozilla)
- Web features explorer: animation-composition (W3C WebDX Community Group)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress