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

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

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 $-60$ 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 $0$ and $1$, the output progress between $0$ and $1$.

Every named function is a **cubic Bezier** curve joining the ends $(0,0)$ and $(1,1)$ 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 $t$, while the time available is on the $x$ axis. First the $t$ that gives the
$x$ value is found, then $y$ is read for that $t$.

```js
// 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 $0.3781$;
at this fraction, the translation is $-3.03$ units, the scale factor $1.0151$.

The formula is the same for every property:

$$
\text{value}(t) = \text{start} + (\text{end} - \text{start}) \times \text{progress}(t)
$$

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 $-3.56$ units, and the return starts from
this value, not from $-8$ 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 $0$, $0.25$, $0.5$, $0.75$, $1$; 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

```css
/* 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 `auto` and
  `display`, 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; `start` drops the first step immediately,
  `end` drops 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.
