Skip to content
academia.sh

Lesson 15 / 23

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.

Contents

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.

@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.

// 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.60.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.42860.4286; because direction is alternate, the timeline reverses on odd-numbered rounds and local progress becomes 10.4286=0.57141 - 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.57140.5714’s ratio within the first interval is 0.95230.9523; the curve pulls this ratio to 0.99560.9956, and opacity becomes 0.35280.3528 — linear interpolation would have given 0.38100.3810. At the exact middle of the interval, the curve is symmetric, so there is no difference: the 0.6750.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.52.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

/* 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?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close