Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GYeokZtSmqg42ZGWsLXX2C
4.7 KiB
4.7 KiB
Patterns, profiling, and cost cheat sheet
Property cost cheat sheet
| Property | Pipeline stage triggered | Verdict |
|---|---|---|
transform |
Composite only | Cheap — animate freely |
opacity |
Composite only | Cheap — animate freely |
filter |
Paint (+composite) | Expensive — avoid animating radius |
box-shadow |
Paint (large area) | Expensive — use pseudo + opacity |
width, height |
Layout + Paint | Avoid — use scale/FLIP |
top, left, right, bottom |
Layout + Paint | Avoid — use translate |
margin, padding |
Layout + Paint | Avoid |
border-width |
Layout + Paint | Avoid |
color, background-color |
Paint | Acceptable in moderation |
background-position |
Paint | Prefer translate on child |
clip-path |
Paint | Moderate; cheaper than filter |
Accordion with grid height (no JS measurement)
<button class="acc-trigger" aria-expanded="false">Details</button>
<div class="acc-wrap"><div class="acc-content">…content…</div></div>
.acc-wrap {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 280ms cubic-bezier(0.2,0,0,1);
}
.acc-wrap.open { grid-template-rows: 1fr; }
.acc-content { overflow: hidden; min-height: 0; }
@media (prefers-reduced-motion: reduce) {
.acc-wrap { transition: none; }
}
trigger.addEventListener('click', () => {
const open = wrap.classList.toggle('open');
trigger.setAttribute('aria-expanded', String(open));
});
FLIP reorder list (batched reads)
function flipReorder(container, mutate) {
const items = [...container.children];
const firsts = new Map(items.map((el) => [el, el.getBoundingClientRect()])); // all reads
mutate(); // e.g. sort/reinsert children
items.forEach((el) => {
const first = firsts.get(el);
const last = el.getBoundingClientRect();
const dx = first.left - last.left;
const dy = first.top - last.top;
if (dx || dy) {
el.animate(
[{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }],
{ duration: 300, easing: 'cubic-bezier(0.2,0,0,1)' }
);
}
});
}
Reading every rect before mutating, and using animate() (compositor), keeps a 50-item reorder smooth.
Just-in-time will-change
el.addEventListener('pointerenter', () => { el.style.willChange = 'transform'; });
el.addEventListener('transitionend', () => { el.style.willChange = 'auto'; });
Promote on intent, demote when the animation ends, so layers don't accumulate.
Reduced-motion gate
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;
}
}
For JS: const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; and skip/shorten animations when true.
Profiling: prove it's compositor-only
- Open DevTools > Performance. Record while the animation runs.
- Inspect the flame chart frames during the animation:
- Purple "Layout" or green "Paint" bars on every frame → still hitting layout/paint; the animation is not compositor-only. Find the offending property.
- Only "Composite Layers" / GPU work per frame → success.
- Use Rendering tab tools:
- Paint flashing — green flashes show repainted regions; a good transform/opacity animation flashes nothing.
- Layer borders — confirms which elements are on their own compositor layer (watch for too many from over-using
will-change). - Frame Rendering Stats / FPS meter — confirm a stable 60/120fps.
- The Layers panel shows layer count and memory; excessive layers indicate
will-changeoveruse.
Parallax without scroll-jank
Drive parallax with transform: translate3d() updated inside a single requestAnimationFrame loop reading scrollY once per frame — never read layout per element per frame.
let ticking = false;
addEventListener('scroll', () => {
if (!ticking) {
requestAnimationFrame(() => {
const y = window.scrollY; // single read
layers.forEach((l, i) => { // writes only
l.style.transform = `translate3d(0, ${y * (0.1 * (i + 1))}px, 0)`;
});
ticking = false;
});
ticking = true;
}
}, { passive: true });
{ passive: true } lets the browser scroll without waiting on the handler.
Stay on transform and opacity, profile the jank away, and motion holds 60fps. Built by iart.ai — the AI motion agent for editable, on-brand motion graphics.