CSS Animation Performance — The Compositor, will-change, and 60fps
Senior guide to fast CSS animations: the render pipeline, compositor-only properties, will-change tradeoffs, avoiding layout thrash, and DevTools measurement.
Animation mượt không nằm ở đường cong easing — nó nằm ở việc browser buộc phải tính lại pixel nào trên mỗi frame. Mục tiêu 60fps cho bạn ~16.7ms mỗi frame; màn 120fps chỉ còn ~8.3ms. Trượt ngân sách đó là người dùng thấy giật.
Bài này tập trung hẳn vào hiệu năng animation. Tối ưu CSS tổng quát đã có bài riêng.
Mở demo đầy đủ: Spawn hàng trăm box animate, bật/tắt compositor vs layout, và xem FPS giảm khi reflow bắt đầu.
The render pipeline: style → layout → paint → composite
Mỗi cập nhật hình ảnh chạy qua một pipeline, và càng đẩy việc xuống cuối thì mỗi frame càng rẻ.
- Style — browser xác định rule nào áp dụng và tính giá trị cuối.
- Layout (reflow) — tính hình học: vị trí và kích thước của mọi box.
- Paint — tô pixel: chữ, màu, viền, bóng vào các layer.
- Composite — GPU ghép các layer đã paint lại thành ảnh cuối.
Điểm mấu chốt: layout đắt nhất, composite rẻ nhất. Animation chỉ chạm bước composite có thể chạy hoàn toàn trên GPU, ngoài main thread.
style ──> layout ──> paint ──> composite
│ │ │ │
every triggers triggers GPU only,
frame paint + composite off main thread
composite
Which property triggers what
Property bạn animate quyết định bao nhiêu phần pipeline chạy lại.
Layout-triggering (most expensive)
Animate những cái này ép reflow, rồi paint, rồi composite — mỗi frame.
/* AVOID animating these — they trigger layout */
.bad {
/* position & box geometry */
top: 0;
left: 0;
right: 0;
bottom: 0;
width: 100px;
height: 100px;
/* box model */
margin: 0;
padding: 0;
border-width: 1px;
}
Một thay đổi layout ở một element có thể lan sang anh em và con, nên chi phí tỉ lệ với DOM.
Paint-triggering (medium cost)
Những cái này bỏ qua layout nhưng vẫn repaint vùng bị ảnh hưởng mỗi frame.
/* Costs a repaint each frame (no reflow) */
.medium {
color: #c8ff00;
background-color: #111;
background-position: 0 0;
box-shadow: 0 0 10px #000;
border-radius: 8px;
outline-color: red;
}
box-shadow và repaint background lớn nặng một cách bất ngờ vì vùng paint có thể rất rộng.
Composite-only (cheapest — the goal)
Bốn cái này có thể animate bằng riêng compositor, không layout, không paint.
/* PREFER these — compositor-only, GPU-accelerated */
.good {
transform: translate3d(0, 0, 0) scale(1) rotate(0deg);
opacity: 1;
/* filter is composited in modern engines, but verify */
filter: blur(0);
}
Muốn dời box? Animate transform: translateX() thay vì left. Muốn đổi size? Animate transform: scale() thay vì width/height.
/* Move 200px: layout vs composite */
/* ❌ reflow every frame */
@keyframes slide-bad {
from { left: 0; }
to { left: 200px; }
}
/* ✅ composite only */
@keyframes slide-good {
from { transform: translateX(0); }
to { transform: translateX(200px); }
}
Why compositor-only stays at 60/120fps
Layout và paint chạy trên main thread — cùng thread với JavaScript, xử lý sự kiện và setTimeout. Nếu main thread bận parse JSON hay chạy handler nặng, animation dựa trên layout sẽ khựng.
Animation chỉ-composite (transform, opacity) có thể giao cho compositor thread và GPU. Chúng vẫn chạy mượt ngay cả khi main thread bị block. Đó là lý do animation transform mượt còn width giật khi tải nặng.
// Demo: block the main thread, then watch which animation survives.
document.querySelector('#block').addEventListener('click', () => {
const end = performance.now() + 500;
while (performance.now() < end) {
// Busy-wait 500ms on the main thread.
}
});
// A transform/opacity animation keeps ticking on the compositor.
// A top/left/width animation freezes for the full 500ms.
will-change: a scalpel, not a hammer
will-change báo cho browser “property này sắp animate, chuẩn bị đi.” Thực tế nó thăng element lên layer compositor riêng từ trước, tránh khựng paint khi animation bắt đầu.
/* Hint before the animation, so layer creation doesn't cost a frame */
.card {
will-change: transform;
}
The memory cost
Mỗi layer được thăng tốn bộ nhớ GPU tỉ lệ với kích thước của nó. Một layer full-screen ở DPR cao có thể tốn vài megabyte. Áp will-change lên hàng trăm element thì có thể cạn bộ nhớ GPU, trớ trêu làm mọi thứ chậm hơn.
Rules for not overusing it
- Áp ngay trước animation rồi gỡ sau đó.
- Đừng bao giờ đặt
will-change: transformtoàn cục trên*hay selector lớn. - Ưu tiên thêm lúc có ý định
:hover/focus thay vì để vĩnh viễn.
/* Add the hint on intent, drop it when idle */
.btn {
transition: transform 150ms ease;
}
.btn:hover {
will-change: transform;
}
// Or manage it imperatively for one-shot animations.
function animateOnce(el) {
el.style.willChange = 'transform, opacity';
el.classList.add('run');
el.addEventListener(
'animationend',
() => { el.style.willChange = 'auto'; },
{ once: true }
);
}
Nếu không có kế hoạch gỡ ra, có lẽ bạn không nên thêm vào.
Promoting elements to their own layer
Trước khi có will-change, dev dùng “hack” translateZ(0) / translate3d(0,0,0) để ép tạo layer.
/* Legacy layer-promotion hack — prefer will-change today */
.layer {
transform: translateZ(0);
}
Layer là sự đánh đổi: layer riêng làm composite rẻ nhưng tạo, paint và lưu layer tốn bộ nhớ và thời gian upload. Chỉ thăng vài element thật sự animate — đừng thăng cả section.
Avoiding layout thrashing
Layout thrashing xảy ra khi JS liên tục ghi rồi đọc layout, ép reflow đồng bộ trong vòng lặp.
// ❌ Forced synchronous layout: read → write → read → write...
const boxes = document.querySelectorAll('.box');
boxes.forEach((box) => {
const w = box.offsetWidth; // READ (forces layout)
box.style.width = w + 10 + 'px'; // WRITE (invalidates layout)
});
Mỗi lần đọc sau khi ghi ép browser tính lại layout ngay lập tức. Cách sửa là gom đọc trước, rồi gom ghi sau.
// ✅ Batch all reads, then all writes — one layout pass.
const boxes = document.querySelectorAll('.box');
const widths = [...boxes].map((box) => box.offsetWidth); // READ phase
boxes.forEach((box, i) => {
box.style.width = widths[i] + 10 + 'px'; // WRITE phase
});
Với vòng lặp animation, lên lịch ghi trong requestAnimationFrame và tránh đọc layout cùng frame sau khi ghi. Thư viện như FastDOM chuẩn hóa việc gom đọc/ghi này.
Reducing paint areas
Khi không tránh được repaint, hãy giữ vùng paint nhỏ. Hình chữ nhật invalidate lớn nghĩa là GPU phải upload lại nhiều texture hơn.
- Cô lập element đang animate để chúng không invalidate cha.
- Dùng
transform/opacityđể không có paint nào. - Dùng
containđể giới hạn layout/paint trong một subtree.
/* Tell the browser this subtree is self-contained */
.widget {
contain: layout paint;
}
contain: layout paint hứa với browser rằng nội bộ element không ảnh hưởng hình học hay pixel bên ngoài, nên nó bỏ qua được nhiều tính toán.
content-visibility, conceptually
content-visibility: auto cho browser bỏ qua việc render nội dung ngoài màn hình cho tới khi cuộn gần viewport.
/* Skip layout/paint for off-screen sections */
.section {
content-visibility: auto;
contain-intrinsic-size: auto 600px; /* reserve space to avoid scroll jumps */
}
Với animation, liên hệ là gián tiếp nhưng có thật: bỏ qua layout/paint của phần ngoài màn hình giúp giải phóng main thread để animation trên màn hình đạt ngân sách frame. contain-intrinsic-size quan trọng để thanh cuộn không nhảy khi nội dung bị bỏ qua.
Measuring with DevTools
Đừng tối ưu bằng cách đoán — hãy đo.
Performance panel
Ghi lại trong lúc animation chạy, rồi tìm:
- Thanh tím dài = Layout/Recalculate Style.
- Thanh xanh lá = Paint.
- Frame rớt dưới FPS mục tiêu trong track frames.
DevTools → Performance → ⏺ Record → run animation → ⏹ Stop
Look at: Main thread track, Frames track, "Summary" pie.
Goal: each frame < 16.7ms (60fps) or < 8.3ms (120fps).
Layers panel
Tab Layers hiển thị mọi layer compositor, kích thước và lý do nó được thăng. Dùng để bắt layer vô tình tạo bởi will-change lạc.
Paint flashing
Bật paint flashing để thấy chính xác vùng nào repaint. Nháy xanh mỗi frame của animation “mượt” nghĩa là nó vẫn đang paint.
DevTools → ⋮ → More tools → Rendering →
✔ Paint flashing (green = repaint regions)
✔ Layer borders (orange = compositor layers)
✔ Frame Rendering Stats (live FPS meter)
Animation transform/opacity đúng chuẩn sẽ không nháy xanh trong lúc chạy.
A practical decision checklist
Trước khi ship bất kỳ animation nào, soát qua danh sách này.
- Có thể diễn đạt bằng
transformhoặcopacitykhông? Có thì làm trước. - Có đang animate property layout không? Thay đi.
- Có repaint không? Kiểm tra paint flashing; thu nhỏ vùng paint hoặc đổi sang chỉ-composite.
- Có thêm
will-changekèm kế hoạch gỡ không? Không thì bỏ. - Có đọc layout trong vòng lặp animation không? Gom đọc và ghi.
- Đã đo ở FPS mục tiêu trên thiết bị thật (tầm trung) chưa, không chỉ laptop nhanh của mình?
- Đã tôn trọng
prefers-reduced-motionchưa?
/* Always honor reduced-motion preferences */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Wrap-up
Animation CSS nhanh chủ yếu là đẩy việc xuống cuối pipeline: ưu tiên composite hơn paint, paint hơn layout, và lý tưởng là chỉ chạm transform và opacity. Dùng will-change có chủ đích, gom đọc/ghi DOM, và để DevTools — không phải trực giác — xác nhận bạn đạt 60/120fps.