Lesson 14 / 23
Transitions
Spreading a change between two states over time; values that can transition and values that cannot, computing the cubic Bezier timing curve, an interrupted transition's behavior, and stepped timing.
Contents
The previous lesson defined the card’s highlighted state: eight units up, four percent larger. The declaration gives both states, but there is no path between them. The moment the pointer is over the card, the card is already in its new place.
Motion is the spreading of the path between two states over time. This lesson covers the declarations that define that path: which property, over how long, along which speed curve, goes from its old value to its new one, and what happens if the path is cut short?
A Transition Fills the Gap Between Two Values
A transition makes a property’s computed value progress from its old value to its new one over time, once that value changes. The transition does not trigger the change; it only spreads a change that was going to happen anyway.
One warning is needed: the Visual Presentation with CSS course covered gradients —
the computed visual between colors, written with functions like linear-gradient(). That
is an unrelated concept; “transition” in this lesson always means change over time, never
a color gradient.
A transition has four separate declarations:
| Declaration | Question it answers |
|---|---|
transition-property |
Which properties will be spread |
transition-duration |
How long the path will take |
transition-timing-function |
How speed is distributed along the path |
transition-delay |
How long after the change it will start |
The transition shorthand takes all four in a single line, and comma-separated lists let
a separate value be written for each property. If duration is not written, its default is
zero; a transition declaration with no duration does nothing.
transition-delay can also take a negative value. A negative delay starts the transition
not from its beginning but from its middle: writing a delay of milliseconds on a
240-millisecond transition opens it already 25 percent along.
Values That Can and Cannot Transition
A transition requires being able to produce an in-between value between two values. This is defined for length, number, color, and transform lists. It is not defined for keywords.
This has consequences. A transition from height: auto to a fixed height does not work,
because auto is not a number. There is no in-between value between display: none and a
visible state; display type is a discrete property, and discrete properties change not
in the middle of a transition but at its end or its start.
If a collapsible section’s height is meant to be spread, there are three routes: transition over a fixed upper bound, transition a grid track’s fractional value, or work with transform and opacity without touching height at all. The third is the cheapest, for reasons the following lessons will show.
The visibility property is an exception: although discrete, it keeps its old value for
the duration of the transition, which is why, when written together with opacity, it
keeps an element clickable while it fades in but not while it fades out.
The Timing Curve
The timing function converts the fraction of elapsed time into a fraction of progress. The input is time between and , the output progress between and .
Every named function is a cubic Bezier curve joining the ends and with
two control points. The notation cubic-bezier(x1, y1, x2, y2) gives these two points.
Evaluating the curve cannot be done in a single step: the curve is defined against the parameter , while the time available is on the axis. First the that gives the value is found, then is read for that .
// transition.mjs — evaluating a cubic Bezier timing curve and computing in-between values // The timing curve is between (0,0) and (1,1), with control points (x1,y1) and (x2,y2). const bez = (a, b, t) => { const u = 1 - t; return 3 * u * u * t * a + 3 * u * t * t * b + t * t * t; // p0=0, p3=1 }; // The curve's parameter t for a given x is found by binary search, then y is read. function curve(x1, y1, x2, y2) { return (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 NAMED = { linear: [0, 0, 1, 1], ease: [0.25, 0.1, 0.25, 1], "ease-in": [0.42, 0, 1, 1], "ease-out": [0, 0, 0.58, 1], "ease-in-out": [0.42, 0, 0.58, 1], }; console.log("--- progress values of the named timing functions ---"); console.log("elapsed".padEnd(12) + Object.keys(NAMED).map((k) => k.padStart(12)).join("")); for (let i = 0; i <= 10; i++) { const x = i / 10; const row = Object.values(NAMED).map((c) => curve(...c)(x).toFixed(4).padStart(12)).join(""); console.log(`%${(x * 100).toFixed(0)}`.padEnd(12) + row); } console.log("\n--- the same progress applied to two properties (240 ms, ease-out) ---"); const f = curve(...NAMED["ease-out"]); const DURATION = 240; const between = (start, end, p) => start + (end - start) * p; console.log("ms".padStart(5), "progress".padStart(10), "translateY".padStart(12), "scale".padStart(10)); for (const ms of [0, 30, 60, 90, 120, 180, 240]) { const p = f(ms / DURATION); console.log( String(ms).padStart(5), p.toFixed(4).padStart(10), (between(0, -8, p).toFixed(2) + "px").padStart(12), between(1, 1.04, p).toFixed(4).padStart(10), ); } console.log("\n--- interrupting the transition midway ---"); const CUT = 72; // ms; pointer leaves the card at the 72nd ms const state = between(0, -8, f(CUT / DURATION)); console.log(`translateY at the moment of interruption = ${state.toFixed(2)}px`); console.log("the return uses the same duration and curve, going from this value to 0:"); for (const ms of [0, 30, 60, 90, 120, 180, 240]) { const p = f(ms / DURATION); console.log(` ${String(ms).padStart(3)} ms -> ${between(state, 0, p).toFixed(2)}px`); } console.log("\n--- discrete progress with steps(), 4 steps ---"); const step = (n, mode) => (x) => { if (x <= 0) return mode === "start" ? 1 / n : 0; if (x >= 1) return 1; const k = Math.floor(x * n); return (mode === "start" ? k + 1 : k) / n; }; const sStart = step(4, "start"); const sEnd = step(4, "end"); console.log("elapsed".padEnd(12) + "steps(4, start)".padStart(16) + "steps(4, end)".padStart(16)); for (let i = 0; i <= 10; i++) { const x = i / 10; console.log( `%${(x * 100).toFixed(0)}`.padEnd(12) + sStart(x).toFixed(2).padStart(16) + sEnd(x).toFixed(2).padStart(16), ); }
--- progress values of the named timing functions ---
elapsed linear ease ease-in ease-out ease-in-out
%0 0.0000 0.0000 0.0000 0.0000 0.0000
%10 0.1000 0.0948 0.0170 0.1606 0.0197
%20 0.2000 0.2952 0.0623 0.3084 0.0817
%30 0.3000 0.5133 0.1296 0.4452 0.1874
%40 0.4000 0.6825 0.2149 0.5709 0.3319
%50 0.5000 0.8024 0.3154 0.6846 0.5000
%60 0.6000 0.8852 0.4291 0.7851 0.6681
%70 0.7000 0.9408 0.5548 0.8704 0.8126
%80 0.8000 0.9756 0.6916 0.9377 0.9183
%90 0.9000 0.9943 0.8394 0.9830 0.9803
%100 1.0000 1.0000 1.0000 1.0000 1.0000
--- the same progress applied to two properties (240 ms, ease-out) ---
ms progress translateY scale
0 0.0000 0.00px 1.0000
30 0.1986 -1.59px 1.0079
60 0.3781 -3.03px 1.0151
90 0.5405 -4.32px 1.0216
120 0.6846 -5.48px 1.0274
180 0.9065 -7.25px 1.0363
240 1.0000 -8.00px 1.0400
--- interrupting the transition midway ---
translateY at the moment of interruption = -3.56px
the return uses the same duration and curve, going from this value to 0:
0 ms -> -3.56px
30 ms -> -2.85px
60 ms -> -2.21px
90 ms -> -1.64px
120 ms -> -1.12px
180 ms -> -0.33px
240 ms -> 0.00px
--- discrete progress with steps(), 4 steps ---
elapsed steps(4, start) steps(4, end)
%0 0.25 0.00
%10 0.25 0.00
%20 0.25 0.00
%30 0.50 0.25
%40 0.50 0.25
%50 0.75 0.50
%60 0.75 0.50
%70 0.75 0.50
%80 1.00 0.75
%90 1.00 0.75
%100 1.00 1.00
The first table compares five curves at the same points in time. Every one of them except
linear is not halfway along the path at half the time.
ease completes half the path at about 30 percent of the duration; it starts fast and
leaves a long, slow tail. ease-in starts slow and speeds up toward the end — at half the
duration, only a third of the path has been covered. ease-out is the reverse of this.
ease-in-out is symmetric and is exactly halfway along the path at exactly half the time.
The choice is not arbitrary. If an element is entering the screen, it is expected to
slow down as it settles (ease-out); if it is leaving the screen, it is expected to
speed up as it goes (ease-in). A symmetric curve is used for an element that stays in
place and changes form.
Computing the In-Between Value
The output’s second block shows how a single progress value is applied to two separate properties. At the 60th millisecond of a 240-millisecond transition, progress is ; at this fraction, the translation is units, the scale factor .
The formula is the same for every property:
For transform lists, this computation is done function by function — if the same functions in the same order are present in both lists, each function’s own numbers are given an in-between value. If the lists do not match, the browser reduces both to a matrix and gives the matrices an in-between value; the result is usually not the expected path. This is why it is a notational rule for the two transform lists entering a transition to carry the same functions in the same order.
Interruption and Return
The output’s third block computes the case where the pointer leaves the card at the 72nd millisecond. At that moment, the translation is units, and the return starts from this value, not from units.
The rule is: when a transition is interrupted, the new transition starts from the property’s computed value at that moment. This is what keeps motion from jumping.
But it leaves a side effect: the return also uses the full duration. The card lifts for 72 milliseconds and descends over 240 milliseconds. A short touch is answered with a long return, and the motion feels delayed. To prevent this, entry and exit durations are written separately: the exit duration is kept shorter than the entry.
A transition declaration, when written on the element’s base state, applies to both
the outward and the return path; when written only on the :hover rule, only the outward
path spreads, and the return is instantaneous.
Stepped Timing
The steps(n, mode) function produces progress not continuously but in steps. The
output’s last block compares two modes with four steps.
end mode starts at zero and drops the last step at the end; start mode drops the first
step immediately and never shows the zero value. With four steps, progress only takes the
values , , , , ; everything in between is skipped.
This is used for a frame-by-frame indicator or a counter. In places where continuity is not wanted, stepped timing makes motion less distracting.
Transition on the Station Page
/* motion.css — step 2: spreading the card highlight over time */ .measurement-cards > .card { transform-origin: center bottom; transition: transform 240ms ease-out, box-shadow 240ms ease-out; } .measurement-cards > .card:hover, .measurement-cards > .card:focus-within { transform: translateY(-8px) scale(1.04); transition-duration: 160ms; }
The transition declaration is written on the base state; the return spreads too. In the highlighted state, the duration is pulled to 160 milliseconds: entry short, return somewhat longer. This ordering makes the sequence both responsive and smooth.
transform and box-shadow were listed individually. Writing transition-property: all
is less to type, but it draws every computed-value change into the transition; a color or
size declaration added later starts spreading unintentionally. Listing properties
prevents this surprise.
Summary
- A transition does not trigger a change; it spreads a property whose computed value is already changing over time, from its old value to its new one.
- Values with no in-between value cannot transition: discrete properties like
autoanddisplay, and keywords, produce a jump rather than a path. - The timing function converts a fraction of elapsed time into a fraction of progress;
named functions are cubic Bezier curves, and other than
linear, half the time is not half the path. - An interrupted transition starts again from the property’s computed value at that moment and uses its full duration; entry and exit durations are balanced by writing them separately.
steps(n, mode)produces progress in steps;startdrops the first step immediately,enddrops the last step at the end.
Next Step
A transition depends on a condition: the value has to change. If a value does not change on its own — a loading indicator that keeps spinning, a warning that alternates between two colors — a transition has no event to trigger it. The next lesson covers the mechanism that writes the timeline into the declaration itself: intermediate stops, repeat count, and direction.
To keep your progress and take notes, Log in
My notes
Log in to take notes.