PaintENGINE-SPECIFICDEVICE-SPECIFICGENERAL

Cheap and Expensive Animation

Why transform and opacity can be driven by the compositor without the main thread — stated as a mechanism, with the conditions under which it is simply not true.

The intent, the obvious build, and why it breaks

Every lesson starts where the work starts: someone wanted an outcome, and the first implementation that comes to mind has a problem.

The question

What actually makes an animated property cheap, and when does the advice to animate transform and opacity stop being correct?

The user intent

Someone wants a menu that slides, a toast that fades, a card that lifts — motion that feels like the interface responding rather than the interface stalling.

The obvious build

There is a list of cheap properties and a list of expensive ones. Animate transform and opacity, never animate left or width, and the animation will be smooth.

Why it breaks

The list is a summary of a mechanism, and the mechanism has preconditions. If the element is not on its own compositing layer, animating transform is not compositor-driven — the transform still has to be applied when the content is drawn, on the main thread's schedule.

How it breaks in a real browser
  • The list is a summary of a mechanism, and the mechanism has preconditions. If the element is not on its own compositing layer, animating transform is not compositor-driven — the transform still has to be applied when the content is drawn, on the main thread's schedule.
  • Even a genuinely composited animation is smooth only while the layer's *content* is unchanged. Animate transform on an element whose children are also re-rendering every frame and you have paid for a layer and kept all the paint work.
  • The advice describes the animation, not the frame. A compositor-driven slide runs perfectly while a long task blocks every button on the page — smooth motion is not evidence of a responsive page (Long Tasks).
  • opacity on non-promoted content is a blend performed at raster time, and it forbids the rasteriser from skipping whatever is underneath. On a large surface this is a real per-frame cost, not a free one.
  • Some properties that look like transforms are not: animating width, top, margin or font-size invalidates layout, so every frame runs geometry for the element and typically its siblings (Layout Thrashing).
  • Promotion can be lost between the frame you tested and the frame the user sees. A new overlapping element, an ancestor gaining a filter, or an engine changing its heuristics all silently move the animation back onto the main thread.
IntentEventStateUI LogicDOM WorkNetworkLayout / PaintPixelsFeedback

What is actually happening

In the browser, not in the framework.

  • A frame can be produced from an existing layer tree without re-running style, layout or paint. The compositor holds already-rasterised textures; drawing one at a different offset, scale, rotation or alpha does not require knowing anything about the DOM (Compositing Layers).
  • transform and opacity are the two properties with this property: neither changes the *content* of the element, only where its already-drawn pixels go and how they blend. Every other visual property changes what is drawn.
  • When the engine sees an animation or transition of only these properties on an element it is willing to promote, it can run that animation on the compositor: it hands the compositor the keyframes and the timing function, and the compositor interpolates per frame by itself.
  • The main thread is then out of the loop entirely for the duration. That is the whole win — not that the property is intrinsically cheap, but that its animation can be delegated to a thread your JavaScript cannot block.
  • The delegation is conditional. It requires promotion, requires the animated properties to be only the compositable ones, and in most engines is abandoned if anything else in the same animation touches a paint or layout property.
  • Animating from JavaScript by writing style.transform each frame is not delegated, even though the property is the same one. The interpolation is your code, running on the main thread, once per frame. The property is cheap; your loop is not.

What this makes the browser do

And which of it is avoidable.

  • For a delegated animation: one promotion, one raster of the layer, then per-frame transform and blend on the compositor. The main thread does nothing after the commit.
  • For a non-delegated animation of the same property: style recalculation for the animated element every frame, then paint of whatever region changed, then composite — all on the main thread.
  • For a layout-affecting property: style, then layout for the element and every box whose geometry depends on it, then paint, then composite, every frame.
  • For a JavaScript-driven animation: your callback, plus style, plus whatever the property invalidates, every frame, inside the frame budget you share with everything else (The Frame Budget).
  • Avoidable: the per-frame main-thread work, by expressing the animation declaratively in CSS or the Web Animations API so the engine is *able* to delegate it.

The mechanism, not the list

Start from what the compositor has: rasterised textures and a tree describing where each one goes. To produce the next frame it needs to know, for each layer, a transform and an alpha. If those are the only things that changed, it has everything it needs. It does not need the DOM, the computed styles, the box geometry or the display list — which is precisely why it does not need the main thread.

transform and opacity are on the cheap list because they are the two properties expressible entirely as "where this existing bitmap goes" and "how it blends". A width change alters what the bitmap should contain. A color change alters what the bitmap should contain. A box-shadow change alters what the bitmap should contain, over a larger area than the element. Those all require going back through paint, and paint requires the main thread.

So the rule is really a conditional: if the element has its own layer, and the animation is declarative, and it touches only compositable properties, then the engine can hand it to the compositor and your JavaScript cannot make it stutter. Break any of the three and you are back on the main thread with a property that happens to have a fashionable name.

What each animated property costs per frame
ChangestylelayoutpaintcompositeWhy
`transform` — CSS animation, promoted elementnononoyesDelegated. The compositor interpolates and redraws the existing texture. The main thread is not involved after the initial commit.
`transform` — CSS animation, not promotedyesnomaybeyesNo layer to move, so the transform is applied where the content is drawn. No layout, but the main thread is in the loop every frame.
`transform` — written per frame from JavaScriptyesnomaybeyesThe interpolation is your callback on the main thread. Same property, entirely different scheduling.
`opacity` — CSS animation, promoted elementnononoyesDelegated: an alpha applied to an existing texture at blend time.
`opacity` — non-promoted contentyesnomaybeyesA blend at raster time, which also forbids skipping the content underneath. Cost scales with the area, not the element count.
`left` / `top` on a positioned elementyesyesyesyesPosition is geometry. Layout runs for the element and anything whose position depends on it, every frame.
`width` / `height`yesyesyesyesThe most expensive common animation: it can reflow siblings, ancestors and text line boxes on every frame.
`background-color`yesnoyesyesNo geometry change, but the fill is re-executed over the element's area every frame.
`filter: blur()`yesnomaybeyesOften promoted and sometimes delegated, but the convolution itself still runs per frame — delegation moves the thread, not the arithmetic.

caveat Every row assumes nothing else on the page changed. In practice an animation shares its frames with your application's own renders, and a delegated animation on a layer whose content is being re-rendered gains you nothing (What a Component Costs to Render).

The same movement, three ways

ENGINE-SPECIFICThe Web Animations API is broadly supported, but which of its animations are compositor-driven is not specified anywhere: Blink and WebKit both delegate transform and opacity keyframes on promoted elements while differing on filters and on composite-mode handling, and only Chromium's devtools currently label an animation as compositor-driven — so in Safari and Firefox the main-thread stress test is the practical check.

The comparison below moves an element the same distance with the same easing over the same duration. The visual output is identical. The difference is entirely in which thread interpolates, and it only becomes visible when the main thread is busy — which is the state a real application spends much of its time in.

Sliding a panel 320px
Main thread, every frame
// (a) layout-invalidating, and hand-interpolated
let x = 0
function step() {
  x += 8
  panel.style.left = `${x}px`   // style + LAYOUT + paint, per frame
  if (x < 320) requestAnimationFrame(step)
}
step()
Declarative, compositable properties only
// (b) the engine owns the whole animation and may delegate it
panel.animate(
  [{ transform: 'translateX(0)' }, { transform: 'translateX(320px)' }],
  { duration: 220, easing: 'ease-out', fill: 'forwards' },
)

/* or in CSS, with the reduced-motion branch alongside it */
.panel { transition: transform 220ms ease-out; }
@media (prefers-reduced-motion: reduce) {
  .panel { transition-duration: 1ms; }
}

Version (a) runs layout for the panel and everything positioned relative to it on every single frame, and the interpolation itself is a main-thread callback — so a long task anywhere in the application stops the animation dead. Version (b) hands the engine the complete animation description up front, which is the precondition for delegating it to the compositor; if the panel is promoted, the main thread does no per-frame work at all. Note carefully that (b) is not *guaranteed* to be delegated — it is merely eligible, and the animations panel is where you confirm it.

When the cheap property is not cheap

The failures below are all cases where the property name says "cheap" and the frame says otherwise. Each has a distinct cause and a distinct fix, and misdiagnosing them is how teams end up adding will-change to everything and making the page worse (Layer Explosion).

A transform animation that is not smooth
TriggerSymptomCauseResponse
Animation stutters only when the app is busySmooth in isolation, janky during data loadingThe animation was never delegated — it is interpolated on the main thread and queues behind your workCheck the animations panel for compositor-driven status; make the animation declarative and confine it to transform/opacity.
Keyframes include background-color alongside transformWhole animation runs on the main thread despite the transformA non-compositable property in the same animation disqualifies delegation in most enginesSplit into two animations so the compositable one can be delegated on its own.
Element is inside a container that sizes to contentLayout appears in the profile during a transform animationSomething in the ancestry is measuring, or a sibling reads geometry each frameFind the forced synchronous layout in the profile; batch reads before writes (Layout Thrashing).
First frame of the animation is blank or staleA visible flash at the start of a drawer or menuThe layer was promoted at animation start and raster had not completedPromote slightly earlier — on pointer-enter or focus — and demote when it ends (Compositing Layers).
Text looks soft during a scale animationBlurry labels while animating, crisp afterwardsThe layer is rasterised once and resampled at intermediate scalesAnimate a wrapper that contains no text, or accept the softness and keep the duration short.
Page is frozen but the spinner keeps spinningUsers report "it looked like it was loading" for a hung stateThe animation is compositor-driven and survives a blocked main threadDo not rely on motion as a liveness signal; announce state through the accessibility tree and add a timeout that shows a real error (Live Regions and Announcement).

How to build it

Most important first.

  • Express animation declaratively — a CSS transition, a CSS animation, or element.animate() — so the engine has the whole animation up front and can choose to delegate it. A requestAnimationFrame loop writing styles removes that option by construction (Yielding and Scheduling).
  • Animate only transform and opacity *in the same animation*. Adding one paint-invalidating property to the keyframes commonly disqualifies the whole thing from delegation.
  • Verify delegation rather than assuming it. The animations tooling and the layers panel tell you whether an animation is running on the compositor; the property name does not.
  • When you need to animate something that is not compositable — a height, a colour, a width — accept the cost honestly and reduce the scope instead: animate a smaller element, contain the invalidation, or find a transform that produces the same impression (CSS Containment).
  • Use scale instead of width/height where the visual result is acceptable, but know what you are trading: a scaled layer is resampled, so text inside it can look soft during the animation and crisp after.
  • Respect prefers-reduced-motion as a first-class branch, not an afterthought. The cheapest animation and the most accessible one are frequently the same one: none.

Keyboard, focus, semantics, announcement

A required field on every lesson in this domain, not a section added when there is room.

  • prefers-reduced-motion: reduce is a real user setting, set by real people, frequently because motion makes them ill. Large translations, parallax, scale and spin can trigger vestibular symptoms — nausea, dizziness, migraine — and the fact that an animation is compositor-driven has no bearing on that whatsoever.
  • Honouring it means offering the state change without the journey: cross-fade instead of slide, or collapse the duration to near-zero. Do not remove the feedback, remove the movement.
  • Compositor-driven animation keeps running while the main thread is blocked, which can actively *hide* a frozen page: the spinner spins, the skeleton shimmers, and nothing is actually progressing. For assistive technology this is worse than a visible stall, because there is no announcement at all — the accessibility tree is updated on the main thread that is stuck (Live Regions and Announcement).
  • Any element whose visibility is animated must have its semantics match its state. A panel faded to opacity: 0 is still focusable and still read by a screen reader unless it is also removed, hidden with visibility, or given inert (Focus Management).
  • Animation that conveys meaning — a row moving to a new position, an item being removed — needs a non-visual equivalent, because a user who has requested reduced motion or cannot see the screen receives none of the information the motion carried.

What can go wrong

Failure modes
  • Silent de-delegation: nothing errors, the animation simply starts running on the main thread and becomes stuttery under load. There is no warning and no exception.
  • A composited animation on a layer whose content is also changing: you have paid the memory and kept the paint. Common on animated cards that also update a live counter.
  • A transform animation that triggers layout in an ancestor because the element is inside a container that sizes to its contents — the transform itself is fine, the surrounding geometry is not.
  • The mitigation failing: will-change added everywhere to guarantee delegation, which allocates so much layer memory that the page becomes slower overall (Layer Explosion).
  • Text legibility during scale animations, and half-pixel blurring when a transform lands on a non-integer device-pixel boundary.
  • A composited animation continuing during a main-thread freeze, so the page passes casual inspection while every interaction is queued behind a long task.
What can arrive out of order
  • A compositor-driven animation and a main-thread style write to the same property race: the compositor may already have produced frames the main thread has not accounted for, so the element visibly jumps when the commit lands.
  • Interrupting an animation mid-flight — a second click before the first transition finishes — races the transitionend handler that was going to clean up the promotion hint, which is how hints leak.
  • Raster of a newly promoted layer can land after the animation's first frame, so an un-hinted animation can start with one stale or blank frame.
Security
  • Animation timing is observable, and has been used for cross-origin inference: an animation whose frame rate depends on whether a resource was cached or whether a link was visited leaks that state. Engines mitigate by restricting what styles are readable for visited links and by coarsening timers.
  • The browser enforces nothing about honesty. An animation can make a page look responsive while it does nothing, or make a control appear to have moved when the real hit target has not — the visual and the interactive are separate, and an attacker can exploit the gap (Clickjacking and Framing).
  • A composited element rendered with near-zero opacity is fully present and fully interactive. Animating opacity to hide something does not remove it from the DOM, from the accessibility tree, or from a screenshot of the DOM.
  • Animation values assembled from user input reach the style system as strings; treat them as untrusted like any other injected value (Sanitization and Trusted HTML).
Misreads
  • "transform is always cheap." It is cheap *when the element has its own layer and the compositor is running the animation*. Written as a universal it is false, and it is false in exactly the cases where you needed it to be true.
  • "Using transform guarantees 60fps." It removes one class of work. The frame is still shared with everything else the browser and your code are doing (The Frame Budget).
  • "will-change: transform makes an animation compositor-driven." It requests promotion. Delegation additionally requires the animation to be declarative and to touch only compositable properties.
  • "A JavaScript animation of transform is the same as a CSS one." The property is the same; the scheduling is not. One is interpolated by the compositor, the other by your code on the main thread.
  • "The animation is smooth, so the page is fine." Compositor-driven motion is the most common way a completely frozen page looks healthy.
  • "Reduced motion is a preference we can ignore for small animations." It is an accessibility setting with a medical basis, and small animations can still trigger symptoms — especially parallax and large translations.

Measuring it, and what changes in the field

How you would see this
  • The animations tooling, which lists running animations and marks which are compositor-driven — the direct answer to "did delegation actually happen".
  • The Performance panel: a delegated animation shows frames produced with no main-thread activity between them, which is the visual signature you are looking for.
  • A deliberate main-thread stress test: run a long synchronous block and watch whether the animation continues. If it stutters, it was never delegated.
  • Layer borders, to confirm the animated element has its own surface and that the surface is the size you expected (Compositing Layers).
  • In the field, this shows up as interaction latency and dropped frames rather than as an animation metric (Vitals in the Field).
Slow device, slow network, large data, old tab
  • On a slow device, the gap between delegated and non-delegated is dramatic. A main-thread animation that costs a few milliseconds per frame on a laptop can exceed the entire budget on a mid-range phone (The Clock Is a Variable).
  • On a high-refresh display, a non-delegated animation has proportionally less time per frame and degrades sooner (The Frame Budget).
  • Under memory pressure, promotion may be declined or a layer evicted, turning a delegated animation into a main-thread one at exactly the moment the device can least afford it.
  • With many simultaneous animations, even compositor-driven ones add up: each is another surface to transform and blend per frame.
  • On a page whose main thread is genuinely idle, the difference is invisible — which is precisely why this is tested with the main thread busy.
What this costs
  • Delegation costs memory. The layer exists for the duration of the animation at minimum, and often longer if you pre-promote to avoid a blank first frame.
  • Expressing everything declaratively means giving up per-frame control. Physics-driven, gesture-following and interruptible animations often genuinely need JavaScript, and the honest answer is to accept main-thread cost and keep the work per frame tiny.
  • Substituting scale for a size animation trades layout cost for resampling artefacts, which is a legibility trade rather than a free one.
  • Designing around compositable properties constrains the design vocabulary. That is a real cost, and it is worth paying only where the motion is frequent or on the critical interaction path.

Where this applies

Frontend advice ages badly and fragments across engines. These labels say what each claim is specific to, and where a different browser, device or framework would differ.

  • ENGINE-SPECIFICWhich animations get delegated to the compositor is an engine decision with engine-specific rules: Blink delegates transform, opacity, filter and backdrop-filter animations on promoted elements and abandons delegation if the keyframes touch anything else, WebKit applies narrower promotion rules and rations layer memory on iOS so an animation delegated in Chrome may run on the main thread in Safari, and Gecko reports compositor-driven animations differently in its own tooling — so verify in each engine rather than transferring a Chrome result.
  • DEVICE-SPECIFICThe advice matters in proportion to how little headroom the device has: on a fast desktop a non-delegated transform animation is usually indistinguishable from a delegated one, while on a low-end phone with a high-refresh display the same animation misses most of its frames.
  • GENERALThe underlying mechanism — a frame can be produced from an unchanged layer tree, and only transform and opacity leave a layer's rasterised content unchanged — is architectural and holds across all three engines even where the promotion heuristics do not.

Where the depth lives

This domain teaches the browser-side mechanism and hands the rest off.

Concurrencyevent-loops
Domains that do not exist yet
  • Software Design — motion as part of a component's contract: who owns the animation, who is allowed to interrupt it, and what state the component is in halfway through.