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

# Animation Performance

Motion's counterpart in the rendering pipeline; which property change reruns the layout, paint, and compositing stages, what a compositing layer is, forced synchronous layout, and the frame budget.

The previous three lessons defined motion: transform gave the geometry, transitions and
keyframes gave the time. The cost was never mentioned.

There are two ways to move a card to the right: increasing the `left` value, or writing a
translation into the `transform` list. On screen, the two look the same. For the browser,
they are not the same work. This lesson establishes where the difference comes from, as a
rule.

## The Rendering Pipeline

A page reaching the screen goes through ordered stages. Four of them matter for motion:

1. **Style calculation.** Which declaration applies to which element is resolved; this is
   where the cascade and inheritance work. Every change runs this stage.
2. **Layout.** Every box's size and place is computed. If a box's width changes, its
   siblings, its parent, and the flow after it can be affected; the computation may not
   stay local.
3. **Paint.** A box's content — color, border, shadow, text — is turned into pixels.
4. **Composite.** Painted surfaces are ordered, transformed, and placed on the screen.

The stages are sequential: if layout reruns, paint and compositing rerun too. But the
reverse does not hold; compositing can run on its own.

## Which Property Triggers Which Stage

```js
// performance.mjs — which pipeline stages a property change triggers, and forced layout count
const PIPELINE = ["style", "layout", "paint", "composite"];

// Rule: from which stage does a property rerun?
const START = {
  width: "layout", height: "layout", "margin-block-start": "layout", padding: "layout",
  "border-width": "layout", "font-size": "layout", "line-height": "layout",
  top: "layout", left: "layout", display: "layout", "flex-basis": "layout",
  color: "paint", "background-color": "paint", "background-image": "paint",
  "border-radius": "paint", "box-shadow": "paint", outline: "paint",
  visibility: "paint", "text-decoration": "paint",
  transform: "composite", opacity: "composite",
};

console.log("--- from which stage a property change reruns ---");
console.log("property".padEnd(20) + PIPELINE.map((a) => a.padStart(10)).join("") + "   stage count");
for (const [property, start] of Object.entries(START)) {
  const i = PIPELINE.indexOf(start);
  const mark = PIPELINE.map((_, k) => (k === 0 || k >= i ? "x" : "-").padStart(10)).join("");
  console.log(property.padEnd(20) + mark + String(PIPELINE.length - i + (i > 0 ? 1 : 0)).padStart(15));
}

console.log("\n--- two notations giving the same visual result ---");
for (const [a, b] of [["left", "transform"], ["width", "transform"], ["visibility", "opacity"]]) {
  const ia = PIPELINE.indexOf(START[a]), ib = PIPELINE.indexOf(START[b]);
  console.log(
    `${a.padEnd(12)} -> ${PIPELINE.slice(ia).join(" > ").padEnd(30)}` +
    `${b.padEnd(12)} -> ${PIPELINE.slice(ib).join(" > ")}`,
  );
}

// --- forced synchronous layout count ---
// Model: a write operation invalidates layout; a subsequent size read forces layout
// to be recomputed to read the invalidated value. One computation happens at frame end anyway.
function layoutCount(operations) {
  let dirty = false, forced = 0;
  const trace = [];
  for (const [kind, name] of operations) {
    if (kind === "write") { dirty = true; trace.push(`write ${name}`); }
    else {
      if (dirty) { forced++; dirty = false; trace.push(`read ${name} -> LAYOUT COMPUTED`); }
      else trace.push(`read ${name}`);
    }
  }
  return { forced, frameEnd: dirty ? 1 : 0, trace };
}

const INTERLEAVED = [
  ["read", "width(card 1)"], ["write", "width(card 1)"],
  ["read", "width(card 2)"], ["write", "width(card 2)"],
  ["read", "width(card 3)"], ["write", "width(card 3)"],
];
const BATCHED = [
  ["read", "width(card 1)"], ["read", "width(card 2)"], ["read", "width(card 3)"],
  ["write", "width(card 1)"], ["write", "width(card 2)"], ["write", "width(card 3)"],
];

for (const [name, operations] of [["reads and writes interleaved", INTERLEAVED], ["reads first, then writes", BATCHED]]) {
  const s = layoutCount(operations);
  console.log(`\n--- ${name} ---`);
  for (const line of s.trace) console.log("  " + line);
  console.log(`  forced layout computations: ${s.forced}, computation at frame end: ${s.frameEnd}`);
}

console.log("\n--- frame budget (arithmetic from refresh rate) ---");
for (const hz of [60, 90, 120, 144]) {
  console.log(`${String(hz).padStart(3)} fps -> ${(1000 / hz).toFixed(2)} ms per frame`);
}
```

```
--- from which stage a property change reruns ---
property                 style    layout     paint composite   stage count
width                        x         x         x         x              4
height                       x         x         x         x              4
margin-block-start           x         x         x         x              4
padding                      x         x         x         x              4
border-width                 x         x         x         x              4
font-size                    x         x         x         x              4
line-height                  x         x         x         x              4
top                          x         x         x         x              4
left                         x         x         x         x              4
display                      x         x         x         x              4
flex-basis                   x         x         x         x              4
color                        x         -         x         x              3
background-color             x         -         x         x              3
background-image             x         -         x         x              3
border-radius                x         -         x         x              3
box-shadow                   x         -         x         x              3
outline                      x         -         x         x              3
visibility                   x         -         x         x              3
text-decoration              x         -         x         x              3
transform                    x         -         -         x              2
opacity                      x         -         -         x              2

--- two notations giving the same visual result ---
left         -> layout > paint > composite    transform    -> composite
width        -> layout > paint > composite    transform    -> composite
visibility   -> paint > composite             opacity      -> composite

--- reads and writes interleaved ---
  read width(card 1)
  write width(card 1)
  read width(card 2) -> LAYOUT COMPUTED
  write width(card 2)
  read width(card 3) -> LAYOUT COMPUTED
  write width(card 3)
  forced layout computations: 2, computation at frame end: 1

--- reads first, then writes ---
  read width(card 1)
  read width(card 2)
  read width(card 3)
  write width(card 1)
  write width(card 2)
  write width(card 3)
  forced layout computations: 0, computation at frame end: 1

--- frame budget (arithmetic from refresh rate) ---
 60 fps -> 16.67 ms per frame
 90 fps -> 11.11 ms per frame
120 fps -> 8.33 ms per frame
144 fps -> 6.94 ms per frame
```

The table is not a measurement, it is a **rule set**: properties split into three groups by
their definition. Ones that touch geometry run from layout onward, ones that touch only
appearance run from paint onward, and ones that change neither the box's size nor its
content run from compositing onward alone.

A handful of properties sit at the edge of the classification — filters and blend modes,
for instance — and which stage they start from depends on the rendering implementation.
The exact information is read from the browser's developer tools rendering log; it should
not be memorized as a rule.

## Same Appearance, Different Cost

The output's second block compares three pairs. Moving with `left` runs three stages,
moving with `transform` runs one. Hiding with `visibility` reruns paint, fading with
`opacity` does not.

From this comes the most practical rule of writing motion: **animations are written
through transform and opacity.** If a box needs to grow, `scale` is used instead of
`width`; if it needs to change place, `translate` is used instead of `left`.

The rule has a cost. `scale` also scales the box's content; text grows as the box grows.
Changing `width` re-wraps the text into new lines. The two do not give the same visual
result, and the choice depends on which result is wanted.

## The Compositing Layer

The reason a transform is cheap is where it is applied. A painted surface is produced
once; transform and opacity are applied to that surface at the compositing stage. The
surface is not repainted, it is only placed differently.

For this, the element needs to be split off onto its own surface — a **compositing
layer**. The browser decides on this split on its own; the `will-change` declaration is a
way to announce that decision ahead of time:

```css
.measurement-cards > .card:hover { will-change: transform; }
```

`will-change` is not a guarantee, it is a **hint**. It has three rules. Splitting off a
layer costs memory; writing it on every element reverses the benefit. The declaration
should be turned on shortly before motion starts and removed once it finishes — a hint
written permanently is a permanent cost. And `will-change` also establishes a stacking
context; the warning from the first lesson applies here too.

## Forced Synchronous Layout

The output's third and fourth blocks count, on a model, a trap that script-driven motion
often falls into. The model is this: a write operation invalidates layout; a subsequent
size-reading operation, since it has to give the current value, forces layout to be
recomputed immediately. This is called **forced synchronous layout**.

When reads and writes are interleaved, the model counts two extra computations. When the
same operations are ordered as all reads first, then all writes, there is no extra
computation; the single computation that would happen at frame's end anyway is enough.

The rule is independent of notation: operations that read a measurement are grouped into
one batch, operations that change a measurement into a separate batch. As the number of
batches grows, so does the number of computations.

## Frame Budget

The last block is an arithmetic conversion: on a screen producing 60 frames per second,
the time per frame is $1000 / 60 \approx 16.67$ milliseconds. This is the budget shared by
every stage of a frame — script, style, layout, paint, composite.

Once the budget is exceeded, a frame is dropped and the motion stutters. This is why the
question to ask when writing motion is not "how long does it take" but "which stages run
per frame." Reducing the stage count is a more reliable path than trying to estimate
duration.

Refresh rate varies by screen; the same animation runs under different budgets. Motion
written to depend on frame count speeds up on a fast screen. Declarations written in terms
of duration do not carry this problem.

## Limiting the Paint Area

An element being repainted can extend to its neighbors too; the paint area can be larger
than the element. The `contain` declaration limits this spread: `contain: layout` declares
that an element's layout computation will not spill outward, `contain: paint` that
painting will not extend outside the box.

This declaration makes a promise; content that does not keep the promise breaks. A shadow
or a dropdown menu overflowing a box carrying the `paint` value is clipped. The declaration
is written where a box is genuinely self-contained.

## Measurement on the Station Page

```css
/* motion.css — step 4: limiting motion's rendering cost */
.measurement-cards > .card {
  transform-origin: center bottom;
  transition: transform 240ms ease-out, box-shadow 240ms ease-out;
  contain: layout paint;
}

.measurement-cards > .card:hover,
.measurement-cards > .card:focus-within {
  transform: translateY(-8px) scale(1.04);
  transition-duration: 160ms;
}

.station-status[data-status="waiting"] .indicator {
  animation: pulse 1400ms 200ms infinite alternate;
  will-change: transform, opacity;
}
```

The card's motion is written only through transform; the `contain` declaration limits the
card's layout and paint effect to its own box. Because the indicator moves continuously,
the `will-change` declaration is written there permanently — permanent motion is one of
the rare cases that justifies a permanent layer.

The shadow transition is a trade-off: `box-shadow` reruns paint. The same appearance can
also be reached with the opacity of a separate pseudo-element placed behind the card, and
that notation runs only compositing.

## Summary

- The rendering pipeline is sequential: if layout runs, paint and compositing run too, but
  compositing can run on its own.
- Properties that touch geometry run from layout, ones that touch only appearance from
  paint, transform and opacity from compositing alone.
- When motion is written through transform and opacity, the stage count drops;
  measurement-changing notations produce the same appearance with more work.
- A compositing layer is the reason a transform is cheap; `will-change` is a hint that
  announces this split and is used temporarily because it costs memory.
- When measurement reads and measurement writes are interleaved, forced synchronous layout
  computations form; batching reads and writes removes this.

## Next Step

Up to this point, how motion is written and what it costs has been established. One
question remains: is motion the same thing for every user? For some users, motion on
screen produces discomfort, dizziness, or nausea, and the operating system announces this
preference. The next lesson covers what the reduced-motion preference means and which
motion should be removed, which one kept.
