Frontend Perf Animation GPU Containment
This skill defines the rules for shipping animations and isolation that stay off the main thread and out of layout / paint storms. The rendering pipeline is Style -> Layout -> Paint -> Composite; transitions and animations are cheap when they only hit Composite, expensive when they hit Layout or Paint per frame. This skill builds on [[frontend-core-architecture]] (where Performance sits in the platform) and is referenced by [[frontend-errors-animation-jank]] (the bug catalog).
Sources : MDN: contain (verified 2026-05-19), MDN: content-visibility (verified 2026-05-19), MDN: will-change (verified 2026-05-19), MDN: @property (verified 2026-05-19), web.dev: Animations guide (verified 2026-05-19).
Quick Reference
The compositor-only rule
ALWAYS animate ONLY the following properties for production interactions :
| Property | Pipeline stage |
|---|---|
transform (translate, scale, rotate, skew, matrix) |
Composite only. |
opacity |
Composite only. |
filter (e.g. blur, drop-shadow, brightness) |
Composite when the layer is already promoted; modern engines treat most filters as compositor-friendly. |
NEVER animate any of the following in a repeated or per-frame transition :
| Property | Why |
|---|---|
top, left, right, bottom, inset |
Triggers Layout. |
width, height, block-size, inline-size |
Triggers Layout. |
margin, padding |
Triggers Layout. |
font-size, letter-spacing, line-height |
Triggers Layout. |
box-shadow |
Triggers Paint per frame. |
background-color, color (when the surface is large) |
Triggers Paint per frame. |
border-radius, border-* widths |
Triggers Layout or Paint depending on context. |
Per web.dev: Animations guide (verified 2026-05-19), profiling shows roughly 0 ms for Layout and Paint when transform is animated, versus tens of ms per frame when top/left is animated on the same scene.
will-change lifecycle
will-change MUST be applied dynamically and removed when the change is done. NEVER ship it statically in the stylesheet for elements that "might" animate.
el.addEventListener('pointerenter', () => { el.style.willChange = 'transform'; });
el.addEventListener('pointerleave', () => { el.style.willChange = 'auto'; });
el.addEventListener('animationend', () => { el.style.willChange = 'auto'; });
el.addEventListener('transitionend', () => { el.style.willChange = 'auto'; });
Per MDN: will-change (verified 2026-05-19) : "will-change is intended to be used as a last resort, in order to try to deal with existing performance problems. It should not be used to anticipate performance problems." Over-using will-change allocates compositor layers and GPU memory the browser would have managed automatically; the result is WORSE performance, not better.
Side effects : will-change that names a stacking-context-creating property (e.g. opacity, transform) creates the stacking context UP FRONT, which may shift visual layering before the animation runs. Account for this when promoting elements that float above siblings.
contain values
Per MDN: contain (verified 2026-05-19), Baseline Widely Available since March 2022.
| Value | What it isolates | Containing block / stacking ctx / BFC |
|---|---|---|
none |
nothing | no |
size |
element size (both axes); element sized as if it had no contents | no |
inline-size |
inline-axis size only | no |
layout |
internal layout (floats, positioning, scroll do not leak out) | YES |
style |
CSS counters and quotes scoped to subtree |
no |
paint |
descendants clipped to the border box, paint stays inside | no |
content |
shorthand for layout paint style |
YES |
strict |
shorthand for size layout paint style |
YES; the element MUST have an explicit size (intrinsic or set) or it collapses to 0 |
contain: content is the safe default for self-contained components (cards, list items, panels). contain: strict is for elements with KNOWN dimensions (image grid cells, virtualised rows) and adds size containment.
content-visibility values
Per MDN: content-visibility (verified 2026-05-19), Baseline 2024 (September 2024).
| Value | Layout / Paint of subtree | Find-in-page | a11y tree | Tab/focus |
|---|---|---|---|---|
visible (default) |
always rendered | yes | yes | yes |
hidden |
skipped, render-state PRESERVED (like display: none but resumes faster) |
NO | NO | NO |
auto |
skipped while OFF-SCREEN, rendered when near viewport; implies contain: layout style paint |
yes | yes | yes |
ALWAYS pair content-visibility: auto with contain-intrinsic-size: auto <length> or contain-intrinsic-size: <length>. Without it, off-screen content is sized to 0; the scrollbar jumps as content materialises and the user's scroll position drifts.
@property for animatable custom properties
Per MDN: @property (verified 2026-05-19), Baseline 2024 (July 2024).
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 0deg;
}
Untyped custom properties cannot be interpolated; transitions and animations on them snap from start to end. Registering with @property (or CSS.registerProperty() from JS) makes the property typed and interpolatable. initial-value MUST be computationally independent (no em, no vh units).
Decision Trees
Decision : can I animate this property safely?
Which property does the transition target?
transform / opacity / filter
-> YES. Composite-only. Ship the animation.
top / left / width / height / margin / padding / font-size /
box-shadow / background-color (large surface)
-> NO. Rewrite using transform or opacity.
- Position move : translate3d(...) / translate()
- Size grow / shrink : transform: scale(...)
- Shadow appear : opacity on a pseudo-element holding the shadow
- Color crossfade on large surface : opacity on a layered overlay,
NOT background-color transition.
A CSS custom property (e.g. --gradient-angle, --progress)
-> Register it with @property to make it typed. Then animate
per the typed-property rules.
Anything else
-> Profile in DevTools Performance. If the property triggers
Layout or Paint on the timeline, treat it as NO and rewrite.
Decision : should I use will-change?
What is the trigger for the change?
User interaction is imminent (pointerenter on a card you will hover-grow,
keydown on a slide deck about to transition).
-> Set will-change on the trigger event. Remove on
transitionend / animationend / interaction-end.
The change is rare or far-future ("might be hovered eventually").
-> NEVER. The browser already promotes elements during active
transitions automatically.
Already-running CSS transition or animation.
-> NEVER add will-change after the animation has started; the
element is already promoted.
The element is animated all the time (sticky badge, ambient bg).
-> Profile first. Many "always animating" effects can be
redesigned to run only when visible or rely on transform
which the engine promotes during the active transition.
Decision : which contain value (or content-visibility)?
What needs to be isolated?
Self-contained component (card, panel, list-item) whose internal
layout, paint, and style counters should not leak.
-> contain: content;
Same plus a KNOWN size (you can give an explicit width/height or
the element has intrinsic dimensions).
-> contain: strict;
Set width and height (or aspect-ratio) explicitly; otherwise
the element collapses to 0.
Only CSS counters/quotes need scoping.
-> contain: style;
Long list / very tall page where off-screen items should not
cost layout/paint.
-> content-visibility: auto;
PLUS contain-intrinsic-size: auto <length>;
Each item should be a separate element with its own
content-visibility rule so the engine can skip each block.
An element that must be rendered but kept ready to swap in
faster than display:none allows.
-> content-visibility: hidden;
Render state preserved across show / hide; find-in-page
and the a11y tree do NOT see it.
Patterns
Pattern 1 : Long virtualised-feel list with content-visibility: auto
Render every item normally; the engine skips layout and paint for items that are far from the viewport. Pair with contain-intrinsic-size so the scrollbar stays correct.
.feed-item {
content-visibility: auto;
contain-intrinsic-size: auto 320px;
}
Full HTML in examples.md (100-item demo).
Pattern 2 : Promote on hover, demote on leave
.card { transition: transform 200ms; }
.card:hover { transform: translateY(-4px); }
card.addEventListener('pointerenter', () => card.style.willChange = 'transform');
card.addEventListener('pointerleave', () => card.style.willChange = 'auto');
The transition itself is composite-only. will-change is added per-interaction so the GPU layer is allocated only while needed.
Pattern 3 : Size animation via transform: scale, NOT width
.bar { transform-origin: left; transform: scaleX(var(--progress)); transition: transform 200ms ease; }
scaleX is a transform; pixel-perfect text inside the bar is achievable by counter-scaling the text child (transform: scaleX(calc(1 / var(--progress)))) when needed.
Pattern 4 : Animatable gradient angle with @property
@property --grad-angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
.hero {
background: linear-gradient(var(--grad-angle), #4d4dff, #ff66cc);
animation: spin 12s linear infinite;
}
@keyframes spin { to { --grad-angle: 360deg; } }
Without @property, the transition would snap at 0deg / 360deg rather than rotating smoothly.
Pattern 5 : Component isolation with contain: content
.card { contain: content; }
Internal floats, positioned descendants, and counter usage do not leak to the page. The card establishes a containing block, stacking context, and BFC; ALWAYS verify positioned descendants still target the intended ancestor (a position: fixed child now positions against the viewport BUT also lives inside a containing block; see the contain MDN reference for details).
Anti-Patterns Index
See anti-patterns.md. Eight cataloged : animating top/left/width/height/margin (jank); will-change always-on (GPU memory drain); will-change never removed; content-visibility: auto without contain-intrinsic-size (scroll jitter); animating box-shadow (paint storm); contain: strict on an element with no explicit size (collapses to 0); requestAnimationFrame infinite loop for state-independent animation (CSS would be cheaper); animating an unregistered custom property (snaps).
Reference Links
- Methods and signatures : full
containmatrix,content-visibilitymatrix,will-changeJS lifecycle,@propertysyntax, rendering-pipeline diagram in text, DevTools panels to inspect each stage. - Examples : working snippets for the five patterns above plus DevTools-driven debugging walkthrough.
- Anti-patterns : eight cataloged anti-patterns with symptom, root cause, and fix.
Cross-references
[[frontend-perf-core-web-vitals-inp]]: LCP / INP / CLS metrics and how compositor-only animation feeds into them.[[frontend-visual-micro-interactions]]: choreography of micro-interactions; uses this skill's compositor rule as a hard constraint.[[frontend-impl-view-transitions-scroll-animations]]: view-transitions and scroll-driven animations both rely on compositor-only properties.[[frontend-errors-animation-jank]]: the bug-catalog companion that diagnoses symptoms back to this skill's rules.[[frontend-syntax-css-color-modern]]:@propertyre-appears for animating colour custom properties.