---
title: 'Keyframe Animations'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/keyframe-animations'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:26+00:00'
license: 'CC BY-SA 4.0'
---

# Keyframe Animations

Animations that carry their own timeline; the definition of a keyframe set, resolving local progress from iteration and direction values, the fill mode's values outside the animation, and the timing function applied to frame intervals.

A transition depends on a condition: a value has to change. If there is no event to change
the value, a transition has no trigger. The measurement station's connection indicator has
to announce "waiting for data" even when nobody touches the page.

The tool for this is the mechanism that writes the timeline into the declaration itself.
This lesson covers how the timeline is defined, which value is in effect at a given
moment, and what happens at times outside the timeline.

## The Keyframe Set

The `@keyframes` rule defines a name and a list of **keyframes**. Every frame carries a
position and the declarations valid at that position. The position is a percentage; the
`from` and `to` keywords correspond to `%0` and `%100`.

```css
@keyframes pulse {
  0%   { opacity: 1;    transform: scale(1);    animation-timing-function: ease-in-out; }
  60%  { opacity: 0.35; transform: scale(1.06); }
  100% { opacity: 1;    transform: scale(1); }
}
```

Four rules govern reading this definition:

1. If `%0` or `%100` is not written, that end is filled with the value **coming from the
   element's own declarations**. This value is called the base value.
2. If two frames are written at the same position, the later one wins; if two
   `@keyframes` rules are written with the same name, the later one replaces the earlier
   one entirely — frames are not merged.
3. An `animation-timing-function` written on a frame applies to the interval **after**
   that frame. If written on the last frame, it has no effect.
4. If a value that cannot transition is written into a frame, that declaration is
   ignored; a declaration carrying `!important` is ignored too.

The third point is the source of a common misunderstanding: the
`animation-timing-function` written on the element does not apply to the animation **as a
whole**, it applies separately to each interval between frames. Writing `ease-in-out` in a
three-frame animation produces two separate slow-fast-slow curves.

## Animation Declarations

| Declaration | Question it answers |
|---|---|
| `animation-name` | Which keyframe set |
| `animation-duration` | How long one round will take |
| `animation-timing-function` | How speed is distributed across frame intervals |
| `animation-delay` | How long the start is postponed |
| `animation-iteration-count` | How many rounds it will run |
| `animation-direction` | Which rounds play in the reverse direction |
| `animation-fill-mode` | Which value is in effect outside the timeline |
| `animation-play-state` | Running, or paused |

The `animation` shorthand takes all of these. A comma-separated list lets more than one
animation be attached to the same element; between two animations writing to the same
property, the one that comes **later** in the list wins.

## Resolving the Timeline

Finding the value in effect at a given moment is four steps: the round number is
subtracted from the elapsed time, the raw progress within the round is found, it is
reversed if needed according to the direction value, then the frame pair corresponding to
that progress is looked up.

```js
// keyframe.mjs — keyframe resolution, iteration, direction, and fill mode
const bez = (a, b, t) => { const u = 1 - t; return 3*u*u*t*a + 3*u*t*t*b + t*t*t; };
const curve = (x1, y1, x2, y2) => (x) => {
  if (x <= 0) return 0;
  if (x >= 1) return 1;
  let lo = 0, hi = 1, t = x;
  for (let i = 0; i < 60; i++) { t = (lo + hi) / 2; if (bez(x1, x2, t) < x) lo = t; else hi = t; }
  return bez(y1, y2, t);
};
const LINEAR = curve(0, 0, 1, 1);
const EASE_IN_OUT = curve(0.42, 0, 0.58, 1);

// @keyframes pulse { 0% {...} 60% {...} 100% {...} }
const FRAMES = [
  { position: 0,   opacity: 1.00, scale: 1.00, curve: EASE_IN_OUT },
  { position: 60,  opacity: 0.35, scale: 1.06, curve: LINEAR },
  { position: 100, opacity: 1.00, scale: 1.00, curve: LINEAR },
];

// value generation from local progress (0..1): find the frame pair, apply that pair's curve
function value(local) {
  const y = local * 100;
  let i = 0;
  while (i < FRAMES.length - 2 && y >= FRAMES[i + 1].position) i++;
  const a = FRAMES[i], b = FRAMES[i + 1];
  const ratio = (y - a.position) / (b.position - a.position);
  const e = a.curve(Math.min(Math.max(ratio, 0), 1));
  return {
    pair: `%${a.position}-%${b.position}`,
    opacity: a.opacity + (b.opacity - a.opacity) * e,
    scale: a.scale + (b.scale - a.scale) * e,
  };
}

const DURATION = 1400, DELAY = 200, ITERATIONS = 3;

// does the round's progress reverse, according to the direction value?
const reversed = (direction, round) =>
  direction === "reverse" ? true :
  direction === "alternate" ? round % 2 === 1 :
  direction === "alternate-reverse" ? round % 2 === 0 : false;

console.log(`duration=${DURATION}ms delay=${DELAY}ms iterations=${ITERATIONS} direction=alternate`);
console.log("\n  ms | round | raw progress | local progress | frame pair | opacity | scale");
for (const ms of [0, 200, 620, 1040, 1600, 2200, 2600, 3000, 3400, 4600]) {
  const elapsed = ms - DELAY;
  if (elapsed < 0) { console.log(`${String(ms).padStart(4)} | delay period, animation has not started yet`); continue; }
  if (elapsed >= DURATION * ITERATIONS) { console.log(`${String(ms).padStart(4)} | animation finished`); continue; }
  const round = Math.floor(elapsed / DURATION);
  const raw = (elapsed % DURATION) / DURATION;
  const local = reversed("alternate", round) ? 1 - raw : raw;
  const d = value(local);
  console.log(
    `${String(ms).padStart(4)} | ${String(round).padStart(5)} | ${raw.toFixed(4).padStart(12)} | ${local.toFixed(4).padStart(15)} | ${d.pair.padStart(10)} | ${d.opacity.toFixed(4).padStart(7)} | ${d.scale.toFixed(4)}`,
  );
}

console.log("\n--- effect of direction values on rounds (r = reversed) ---");
console.log("direction".padEnd(20) + [0, 1, 2, 3].map((n) => `round ${n}`.padStart(9)).join(""));
for (const direction of ["normal", "reverse", "alternate", "alternate-reverse"]) {
  console.log(direction.padEnd(20) + [0, 1, 2, 3].map((n) => (reversed(direction, n) ? "r" : "-").padStart(9)).join(""));
}

// Fill mode becomes visible in a one-round entrance animation:
// @keyframes appear { from { opacity: 0; translateY 8px } to { opacity: 1; translateY 0 } }
console.log("\n--- fill mode: values outside the 'appear' animation ---");
const APPEAR = { start: { opacity: 0, translation: 8 }, end: { opacity: 1, translation: 0 } };
const g = (d) => `opacity=${d.opacity.toFixed(2)} translateY=${d.translation}px`;
// Base value = the value coming from the element's own declarations; two separate bases are tried.
for (const BASE of [{ opacity: 1, translation: 0 }, { opacity: 0, translation: 8 }]) {
  console.log(`\nbase value: ${g(BASE)}`);
  for (const mode of ["none", "forwards", "backwards", "both"]) {
    const before = (mode === "backwards" || mode === "both") ? APPEAR.start : BASE;
    const after = (mode === "forwards" || mode === "both") ? APPEAR.end : BASE;
    console.log(`  ${mode.padEnd(10)} during delay: ${g(before).padEnd(28)} after finishing: ${g(after)}`);
  }
}
```

```
duration=1400ms delay=200ms iterations=3 direction=alternate

  ms | round | raw progress | local progress | frame pair | opacity | scale
   0 | delay period, animation has not started yet
 200 |     0 |       0.0000 |          0.0000 |     %0-%60 |  1.0000 | 1.0000
 620 |     0 |       0.3000 |          0.3000 |     %0-%60 |  0.6750 | 1.0300
1040 |     0 |       0.6000 |          0.6000 |   %60-%100 |  0.3500 | 1.0600
1600 |     1 |       0.0000 |          1.0000 |   %60-%100 |  1.0000 | 1.0000
2200 |     1 |       0.4286 |          0.5714 |     %0-%60 |  0.3528 | 1.0597
2600 |     1 |       0.7143 |          0.2857 |     %0-%60 |  0.7016 | 1.0275
3000 |     2 |       0.0000 |          0.0000 |     %0-%60 |  1.0000 | 1.0000
3400 |     2 |       0.2857 |          0.2857 |     %0-%60 |  0.7016 | 1.0275
4600 | animation finished

--- effect of direction values on rounds (r = reversed) ---
direction             round 0  round 1  round 2  round 3
normal                      -        -        -        -
reverse                     r        r        r        r
alternate                   -        r        -        r
alternate-reverse           r        -        r        -

--- fill mode: values outside the 'appear' animation ---

base value: opacity=1.00 translateY=0px
  none       during delay: opacity=1.00 translateY=0px  after finishing: opacity=1.00 translateY=0px
  forwards   during delay: opacity=1.00 translateY=0px  after finishing: opacity=1.00 translateY=0px
  backwards  during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px
  both       during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px

base value: opacity=0.00 translateY=8px
  none       during delay: opacity=0.00 translateY=8px  after finishing: opacity=0.00 translateY=8px
  forwards   during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px
  backwards  during delay: opacity=0.00 translateY=8px  after finishing: opacity=0.00 translateY=8px
  both       during delay: opacity=0.00 translateY=8px  after finishing: opacity=1.00 translateY=0px
```

Every row of the first table resolves one moment. At the 1040th millisecond, elapsed time
is 840 milliseconds, round zero, raw progress $0.6$; because this value falls exactly on
the third frame, the pair reads as `%60-%100` and the values are that frame's
declarations.

At the 2200th millisecond, two steps are visible at once. Round one, raw progress
$0.4286$; because direction is `alternate`, the timeline reverses on odd-numbered rounds
and local progress becomes $1 - 0.4286 = 0.5714$. The frame pair is looked up against this
local value.

Frame intervals are not equal in length: the first interval is 60 percent of a round, the
second 40 percent. The `ease-in-out` written on the `%0` frame only runs on the first
interval, and its effect becomes visible at the 2200th millisecond. Local progress
$0.5714$'s ratio within the first interval is $0.9523$; the curve pulls this ratio to
$0.9956$, and opacity becomes $0.3528$ — linear interpolation would have given $0.3810$. At
the exact middle of the interval, the curve is symmetric, so there is no difference: the
$0.675$ value at the 620th millisecond comes out the same both ways.

## Direction and Iteration

`animation-iteration-count` takes a number or `infinite`. A fractional value is also
valid: writing $2.5$ cuts the animation in the middle of its third round.

The second table shows which rounds each of the four direction values reverses. `normal`
reverses none, `reverse` reverses all. `alternate` plays even-numbered rounds forward and
odd-numbered rounds reversed; `alternate-reverse` is the opposite of this.

The distinction is behavior, not numbers: with `alternate`, the animation returns to where
it started and there is no jump. With `normal`, an animation running three rounds
**jumps** from the last frame to the first frame at the end of every round. If the frames
do not carry the same value at both ends, this jump is visible.

## Fill Mode

An animation falls outside two time intervals: the delay period and after it finishes.
`animation-fill-mode` declares which value is in effect during these intervals.

The third block computes four modes for two different base values and ties the choice to
a rule. While the base value is `opacity: 1` — that is, while the element is already
visible in the style file — `forwards` and `none` give the same result, because the
animation's last frame is also the same as the base. What makes the distinction is
`backwards`: it applies the first frame during the delay and prevents the element from
appearing for a moment and then disappearing.

While the base value is `opacity: 0`, the situation reverses. The element is hidden in the
style file; with `none`, once the animation finishes, it returns to its hidden state and
the entrance it made goes to waste. What is needed here is the `forwards` value.

The rule is: fill mode is needed when the values at the animation's ends do not match the
element's base value. If the two match, which mode is written makes no visible
difference.

## Pulse on the Station Page

```css
/* motion.css — step 3: measurement indicator waiting for data */
@keyframes pulse {
  0%   { opacity: 1;    transform: scale(1);    animation-timing-function: ease-in-out; }
  60%  { opacity: 0.35; transform: scale(1.06); }
  100% { opacity: 1;    transform: scale(1); }
}

.station-status[data-status="waiting"] .indicator {
  animation: pulse 1400ms 200ms infinite alternate;
  transform-origin: center;
}

.station-status[data-status="offline"] .indicator {
  animation: none;
}
```

The indicator only moves in the waiting state. Status information is read from the
`data-status` attribute; attribute selectors were defined in the Visual Presentation with
CSS course. In the offline state, the animation is explicitly turned off — silence is a
declaration too.

Because the motion runs without end, a responsibility arises here: an animation that keeps
running forever is an attention-grabber the user cannot stop. This topic's last lesson
covers that responsibility.

## Summary

- `@keyframes` defines a timeline; unwritten ends are filled with the element's base
  value, and a second rule with the same name replaces the earlier one entirely.
- The value at a given moment resolves in four steps: round number, raw progress within
  the round, reversal according to the direction value, and finding the frame pair.
- The timing function applies not to the animation as a whole but **to each frame
  interval** separately; the curve written on a frame applies to the interval after that
  frame.
- `alternate` removes the jump at round ends by playing odd-numbered rounds in reverse;
  with `normal`, there is a jump from the last frame to the first.
- Fill mode only produces a visible effect when the animation's end values differ from
  the element's base value.

## Next Step

These three lessons defined motion, but never discussed its cost. An animation that
changes scale looks the same as one that changes width; for the browser, the two are not
the same work. The next lesson covers motion's counterpart in the rendering pipeline:
which property reruns which stage, and why does that determine how smooth the motion is?
