Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 25 minutes
  • Examples run with Node.js 24.21.0 and quickjs 0.32.0
  • By MySmartCoPilot
Free: 25 minutes

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 rings, a skeleton that pulses, and their reduced-motion versions CSS · bell.css
/* 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 from or to keyframe, 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-function shapes each interval separately, so with ease-in-out the bell slows down at every turn, not once over the whole cycle. A keyframe can set its own animation-timing-function for the interval that follows it.
  • !important is ignored inside keyframes. The specification makes such declarations invalid. On the composition page further down, a from keyframe of opacity: 0.2 !important was 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 angle through one cycle JavaScript · ring.mjs
// 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

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:

The bell and the skeleton, measured HTML · bell.html
<!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">&nbsp;</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 three phases of a CSS animation, before, active and after, with what each fill mode shows in them.Before: the delaybackwards or both: the first keyframenone or forwards: the element's own styleActive: duration × iteration counteach iteration runs from 0% to 100%alternate: every second one runs backpaused: the clock stops where it isAfter: the animation has endedforwards or both: where it stoppednone or backwards: the element's own stylethe delay is overthe last iteration ends

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.

  1. 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.
  2. 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.
  3. 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:

Fill modes and directions, sampled at fixed times HTML · fill-and-direction.html
<!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.

replace, add and accumulate, measured at the end of each animation HTML · composition.html
<!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:

One loader moved with left, the other with translate CSS · loader.css
/* 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; }
The two loader pages

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.

CSS Loader & Spinner Generator Pick a spinner, dots or a skeleton and copy CSS that animates transform and opacity.

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.

CSS Animation & Keyframes Generator Build a keyframe animation from presets and copy it with a reduced-motion guard already written.

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.

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.

  1. Question 1 of 6 In animation: pulse 2s 500ms infinite, what does 500ms set?

    Choose one answer.

    Show the answer to question 1

    Answer: The delay before the first iteration

    The first time in the shorthand is the duration (2s) and the second is the delay (500ms). CSS animations have no setting for a gap between iterations; build a rest into the keyframes instead, as the bell does from 40 % to 100 %.

  2. Question 2 of 6 An animation has a 1 s delay, and its first keyframe sets opacity 0. Which fill mode keeps the element invisible during the delay?

    Choose one answer.

    Show the answer to question 2

    Answer: backwards (or both)

    backwards applies the first keyframe during the delay; both adds the end state too. With none or forwards the element shows its own style, fully visible, until the delay ends and then jumps to opacity 0. paused is a play state, not a fill mode.

  3. Question 3 of 6 @keyframes slide moves translate from 0 to 100px, and an element has animation: slide 1000ms linear 2 alternate. Where is it 1250 ms after the animation starts?

    Type a number.

    Show the answer to question 3

    Answer: 75 px

    1250 ms is a quarter of the way into the second iteration. With alternate, the second iteration runs backwards, so the progress is 1 − 0.25 = 0.75, and the element is at 75px.

  4. Question 4 of 6 Which of these declarations inside a keyframe block are ignored?

    Choose every answer that is right.

    Show the answer to question 4

    Answer:

    • opacity: 0.2 !important
    • animation-duration: 2s

    Declarations with !important are invalid in keyframes, and the animation properties are ignored there, with one exception: animation-timing-function, which sets the easing of the interval that starts at that keyframe. Ordinary animatable properties such as translate are what keyframes are for.

  5. Question 5 of 6 A loader moves a dot 200px back and forth forever. In the lesson's Chromium trace, which way of moving it needed no layout and no paint on each frame?

    Choose one answer.

    Show the answer to question 5

    Answer: Animating translate

    Animating left produced 121 layout events and 242 paint events in 2 seconds; animating translate produced none, only style updates and compositing. margin-left and width change the layout just like left.

  6. Question 6 of 6 A skeleton row pulses forever beside a form that people are filling in. Which WCAG 2.2 success criterion asks for a way to pause, stop or hide it?

    Choose one answer.

    Show the answer to question 6

    Answer: 2.2.2 Pause, Stop, Hide

    2.2.2 covers moving or blinking content that starts by itself, lasts more than five seconds and appears next to other content. 2.3.3 is about motion that an interaction starts, and 2.3.1 about flashes.

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

  • @keyframes lists the styles at points from 0 % to 100 %; missing ends come from the element’s own values, !important inside keyframes is ignored, and the easing applies to each interval between keyframes.
  • The animation shorthand 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); both does both.
  • animation-composition: add or accumulate combines animations with the value underneath instead of replacing it.
  • Animating left cost a layout and two paint events per frame in Chromium; translate cost neither. Prefer transforms and opacity, and use will-change sparingly.
  • 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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.