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:
- If
%0or%100is not written, that end is filled with the value coming from the element’s own declarations. This value is called the base value. - If two frames are written at the same position, the later one wins; if two
@keyframesrules are written with the same name, the later one replaces the earlier one entirely — frames are not merged. - An
animation-timing-functionwritten on a frame applies to the interval after that frame. If written on the last frame, it has no effect. - If a value that cannot transition is written into a frame, that declaration is
ignored; a declaration carrying
!importantis 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 ; 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
; because direction is alternate, the timeline reverses on odd-numbered rounds
and local progress becomes . 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
’s ratio within the first interval is ; the curve pulls this ratio to
, and opacity becomes — linear interpolation would have given . At
the exact middle of the interval, the curve is symmetric, so there is no difference: the
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 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
@keyframesdefines 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.
alternateremoves the jump at round ends by playing odd-numbered rounds in reverse; withnormal, 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.