git clone https://github.com/Intense-Visions/harness-engineering
T=$(mktemp -d) && git clone --depth=1 https://github.com/Intense-Visions/harness-engineering "$T" && mkdir -p ~/.claude/skills && cp -r "$T/agents/skills/claude-code/perf-paint-compositing" ~/.claude/skills/intense-visions-harness-engineering-perf-paint-compositing-1b1e75 && rm -rf "$T"
agents/skills/claude-code/perf-paint-compositing/SKILL.mdPaint and Compositing
Understand the browser's paint and compositing pipeline — how content is rasterized into layers, which properties trigger expensive repaints, how GPU compositing enables 60fps animations, and how to manage layer promotion without exhausting GPU memory.
When to Use
- Animations run below 60fps and DevTools shows green "Paint" blocks in the flame chart
- The Layers panel in DevTools shows an unexpected number of composited layers
- GPU memory usage is high and you suspect excessive layer promotion
- You are choosing between animating
/left
versustoptransform: translate()
is applied to many elements and you need to understand the memory trade-offwill-change- Scroll performance is poor and DevTools shows paint operations during scroll
- Complex visual effects (shadows, gradients, filters) cause frame drops
- Mobile devices show worse animation performance than desktop despite similar logic
- You need to understand which CSS properties are "compositor-only" and skip the main thread
- A page with many animated elements needs a layer management strategy
Instructions
-
Understand the two-thread model. The browser has a main thread (runs JavaScript, style, layout, paint records) and a compositor thread (composites layers, handles scroll, runs transform/opacity animations). Properties that only affect compositing (
,transform
) animate on the compositor thread without blocking JavaScript execution.opacity -
Identify compositor-only properties. Only these CSS properties can be animated without triggering layout or paint:
— translate, scale, rotate, skew (GPU-composited)transform
— alpha blending on the GPUopacity
— GPU-accelerated in most browsers (but check paint in DevTools)filter
— composited but expensive on mobilebackdrop-filter
/* EXPENSIVE — triggers Layout + Paint + Composite every frame */ .slide { transition: left 0.3s, top 0.3s; } /* CHEAP — Composite only, runs on compositor thread */ .slide { transition: transform 0.3s; } -
Promote elements to their own layer deliberately. Layer promotion moves an element to a separate GPU texture, allowing the compositor to animate it independently. Promote with:
/* Promote only when animation is about to start */ .card.will-animate { will-change: transform; } /* Alternative: 3D transform hack (older technique) */ .promoted { transform: translateZ(0); } -
Remove
after animations complete. Each promoted layer consumes GPU memory (the element is rasterized to a separate texture). Remove the hint when not needed:will-changeelement.addEventListener('mouseenter', () => { element.style.willChange = 'transform'; }); element.addEventListener('transitionend', () => { element.style.willChange = 'auto'; }); -
Use the Layers panel to audit composited layers. In Chrome DevTools, open the Layers panel (More tools > Layers). Each green-outlined rectangle is a composited layer. Check:
- Total layer count (target: fewer than 30 on mobile)
- Layer sizes (large layers consume significant GPU memory)
- Compositing reasons (shown when selecting a layer)
-
Reduce paint complexity. Some CSS properties are expensive to paint:
— especially with large blur radius (>10px), painted per frame during animationbox-shadow
withborder-radius
— requires clipping maskoverflow: hidden- CSS
— full-surface Gaussian blurfilter: blur() - Complex
— repainted on size changesbackground: linear-gradient()
Prefer pre-rendered images or SVGs for complex visual effects that change frequently.
Details
Paint and Rasterization Architecture
After layout, the browser creates a "paint record" — an ordered list of drawing commands (draw rectangle, draw text, draw image). This record is then rasterized into pixels. Modern browsers use two rasterization strategies:
- Software rasterization — CPU draws pixels into a bitmap. Used for simple content. Blink uses Skia as the rasterization engine.
- GPU rasterization — Drawing commands are sent to the GPU via OpenGL/Vulkan/Metal. Used for complex content, transforms, and promoted layers. Faster for large areas but has texture upload overhead.
Rasterization is tiled: the page is divided into 256x256px tiles, and only visible tiles are rasterized. During scroll, new tiles are rasterized on background threads (off-main-thread rasterization).
Worked Example: Airbnb Parallax Scroll
Airbnb's listing page had a parallax hero image that animated at 15fps on mobile. The implementation used
background-position to create the parallax effect, which triggers paint on every scroll frame because the browser must re-rasterize the background at a new position.
/* BEFORE: 15fps — triggers paint on every scroll frame */ .hero { background-position: center calc(50% + var(--scroll-offset)); } /* AFTER: 60fps — compositor-only, no paint */ .hero-image { will-change: transform; transform: translate3d(0, var(--scroll-offset), 0); }
The fix moved from
background-position (paint per frame) to transform: translate3d() (compositor-only). Frame rate went from 15fps to 60fps because the compositor thread handles the transform without involving the main thread.
Worked Example: Dashboard Layer Memory Explosion
A real-time dashboard applied
will-change: transform to all 50 chart widgets for smooth updates. Each widget was 400x300px at 2x device pixel ratio, creating 50 layers at 4003004*4 = 1.92MB each (RGBA, 2x DPR) = 96MB of GPU memory just for chart layers. On mobile devices with 256MB GPU memory budget, this caused texture eviction and janky re-rasterization.
Fix: apply
will-change only to the chart currently being updated, remove it after the update animation completes. GPU memory dropped from 96MB to ~4MB (2 active layers at any time).
Layer Promotion Triggers
Elements are promoted to their own composited layer when:
,will-change: transform
, orwill-change: opacity
is setwill-change: filter- 3D transforms are applied (
,transform: translate3d()
)transform: translateZ()
,<video>
, or<canvas>
elements (always composited)<iframe>
orposition: fixed
elementsposition: sticky- An element overlaps an already-composited layer (implicit promotion to maintain correct z-order)
- CSS animations or transitions on
ortransformopacity
Implicit promotion (overlap-based) is a common source of unexpected layer count increases. Use the Layers panel to identify "compositing reason: overlaps other composited content."
Anti-Patterns
Blanket
on all elements. Each promoted layer is rasterized to a separate GPU texture. Applying will-change: transform
will-change to 50+ elements on a page with no active animations wastes GPU memory and can cause worse performance than no promotion at all due to texture management overhead.
Animating
directly. box-shadow
box-shadow triggers paint on every frame. For animated shadows, use a pseudo-element with the shadow pre-applied and animate its opacity:
.card { position: relative; } .card::after { content: ''; position: absolute; inset: 0; box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); opacity: 0; transition: opacity 0.3s; } .card:hover::after { opacity: 1; }
Excessive layer count on mobile. Mobile GPUs have limited texture memory (128-512MB shared across all apps). More than 30 composited layers on mobile risks texture eviction, where the GPU must re-upload textures from main memory, causing visible jank during scroll or animation.
hack applied globally. This was a common trick to force GPU compositing, but applying it to all elements creates unnecessary layers, wastes GPU memory, and can cause text rendering differences (subpixel antialiasing is disabled on composited layers in some browsers).backface-visibility: hidden
Animating
or border-radius
directly. These trigger paint recalculation per frame. Instead, pre-create the clipped shape and animate clip-path
transform or opacity on the container.
Source
- Chrome Compositing documentation — https://chromium.googlesource.com/chromium/src/+/HEAD/docs/gpu/
- Surma, "The Anatomy of a Frame" (Google) — https://aerotwist.com/blog/the-anatomy-of-a-frame/
- Paul Lewis, "Stick to Compositor-Only Properties" — https://web.dev/articles/stick-to-compositor-only-properties-and-manage-layer-count
- Chromium GPU Architecture documentation
Process
- Read the instructions and examples in this document.
- Apply the patterns to your implementation, adapting to your specific context.
- Verify your implementation against the details and edge cases listed above.
Harness Integration
- Type: knowledge — this skill is a reference document, not a procedural workflow.
- No tools or state — consumed as context by other skills and agents.
Success Criteria
- The patterns described in this document are applied correctly in the implementation.
- Edge cases and anti-patterns listed in this document are avoided.
- Animations use compositor-only properties and achieve 60fps in DevTools Performance panel.
- GPU memory usage is monitored and layer count stays under budget.