CSS Transforms for Animation — 2D, 3D, and the Compositor Win
Senior guide to animating CSS transforms: translate/scale/rotate, transform-origin, individual transform properties, 3D and perspective, and why transforms are cheap.
Transforms are the workhorse of high-performance web animation. {Transform là công cụ chủ lực của animation web hiệu năng cao.} If you only learn one CSS animation primitive deeply, make it this one. {Nếu chỉ học sâu một primitive animation CSS, hãy chọn cái này.}
This guide walks from 2D basics up to a real 3D card flip, and ends with the reason transforms beat top/left/width every time. {Bài này đi từ 2D cơ bản đến card flip 3D thật, và kết bằng lý do transform luôn thắng top/left/width.}
Drag sliders to build a live transform string, flip transform order side-by-side, and compare compositor-friendly motion vs top/left. {Kéo slider để dựng chuỗi transform trực tiếp, so sánh thứ tự hàm, và xem motion trên compositor vs top/left.}
Open the full demo {Mở demo đầy đủ}: /tools/css-transforms-demo/.
What transform actually does
The transform property applies a geometric operation to an element after layout is computed. {Property transform áp dụng phép biến đổi hình học sau khi layout đã tính xong.} The element keeps its original box in the document flow — neighbors do not move. {Element giữ nguyên box gốc trong document flow — các phần tử xung quanh không bị xê dịch.}
That single fact is why transforms are cheap: they never trigger layout (reflow) of the page. {Chính điều đó làm transform rẻ: nó không bao giờ kích hoạt layout (reflow) của trang.}
.box {
/* Visually shifts the box without affecting siblings' positions. */
transform: translateX(40px);
}
2D transforms: the four primitives
There are four core 2D transform functions. {Có bốn hàm transform 2D cốt lõi.} Each takes one or two arguments and they can be combined in a single transform value. {Mỗi hàm nhận một hoặc hai tham số và có thể kết hợp trong cùng một giá trị transform.}
.translate {
/* Move along X / Y. Percentages are relative to the element's own size. */
transform: translate(20px, -10px);
}
.scale {
/* Grow/shrink. 1 = original, 0.5 = half, 2 = double. */
transform: scale(1.2); /* uniform */
transform: scale(1.2, 0.8); /* x, y separately */
}
.rotate {
/* Positive = clockwise. Units: deg, rad, turn. */
transform: rotate(15deg);
}
.skew {
/* Slants the element. Rarely animated, great for stylistic shears. */
transform: skew(10deg, 0deg);
}
A key detail about translate with percentages: translateX(100%) moves the element by its own width, not the parent’s. {Một chi tiết quan trọng về translate với phần trăm: translateX(100%) dịch element theo chiều rộng của chính nó, không phải của cha.} This makes off-screen slide-ins trivial. {Điều này làm cho hiệu ứng trượt vào từ ngoài màn hình trở nên đơn giản.}
.drawer {
/* Park the drawer fully off-screen to the left. */
transform: translateX(-100%);
transition: transform 0.3s ease;
}
.drawer.open {
transform: translateX(0);
}
transform-origin: the pivot point
Every transform happens around an origin. {Mọi transform diễn ra quanh một điểm gốc.} By default that is the element’s center (50% 50%). {Mặc định đó là tâm element (50% 50%).} Change it to rotate or scale around a corner or edge. {Đổi nó để xoay hoặc scale quanh một góc hay cạnh.}
.spinner {
/* Rotate around the top-left corner instead of the center. */
transform-origin: top left; /* keywords */
transform-origin: 0 0; /* equivalent length form */
transform: rotate(45deg);
}
.fan-card {
/* Cards fanning out from a bottom-center pivot, like a hand of cards. */
transform-origin: bottom center;
transform: rotate(-8deg);
}
transform-origin accepts a third value for the Z axis, which matters once you move into 3D. {transform-origin nhận giá trị thứ ba cho trục Z, điều này quan trọng khi bạn bước vào 3D.}
Order matters: transforms are not commutative
Transform functions apply right to left conceptually, but the practical rule is: the listed order changes the result. {Các hàm transform áp dụng theo thứ tự phải sang trái về mặt khái niệm, nhưng quy tắc thực tế là: thứ tự liệt kê làm đổi kết quả.} Translate-then-rotate is not the same as rotate-then-translate. {Translate rồi rotate khác với rotate rồi translate.}
/* A: move right 100px, THEN rotate around the new position's origin. */
.a {
transform: translateX(100px) rotate(45deg);
}
/* B: rotate first (axes now tilted), THEN translate along the tilted X. */
.b {
transform: rotate(45deg) translateX(100px);
}
In case A the element slides 100px right, then spins in place. {Ở trường hợp A element trượt 100px sang phải, rồi xoay tại chỗ.} In case B the element rotates first, so the subsequent translateX moves it diagonally along the rotated axis. {Ở trường hợp B element xoay trước, nên translateX sau đó dịch nó theo đường chéo theo trục đã xoay.}
When you mix them, read the chain as a coordinate-system pipeline: each function transforms the coordinate space the next function operates in. {Khi trộn chúng, hãy đọc chuỗi như một pipeline hệ tọa độ: mỗi hàm biến đổi không gian tọa độ mà hàm kế tiếp thao tác lên.}
Individual transform properties (2022+)
Modern CSS exposes translate, rotate, and scale as standalone properties. {CSS hiện đại expose translate, rotate, và scale thành các property độc lập.} They are baseline across all modern browsers since 2022–2023. {Chúng đã là baseline trên mọi trình duyệt hiện đại từ 2022–2023.}
.card {
translate: 20px 0; /* like translate(20px, 0) */
rotate: 15deg; /* like rotate(15deg) */
scale: 1.1; /* like scale(1.1) */
}
How they compose with transform
The browser composes them in a fixed order: translate, then rotate, then scale, and finally whatever is in the transform property. {Trình duyệt ghép chúng theo thứ tự cố định: translate, rồi rotate, rồi scale, và cuối cùng là nội dung trong property transform.}
.composed {
translate: 50px 0;
rotate: 30deg;
scale: 1.2;
/* Applied last, on top of the three above. */
transform: skewX(8deg);
}
The big practical win: you can animate each axis independently without clobbering the others. {Lợi ích thực tế lớn: bạn có thể animate từng trục độc lập mà không ghi đè các trục khác.}
/* With the legacy single `transform`, hover would WIPE the rotate. */
.legacy:hover {
transform: scale(1.1); /* ❌ loses any rotate set elsewhere */
}
/* Individual properties keep concerns separate. */
.modern {
rotate: 5deg; /* set once, stays put */
transition: scale 0.2s ease;
}
.modern:hover {
scale: 1.1; /* ✅ rotate is untouched */
}
This also unlocks separate transitions and keyframes per property — a different duration for scale vs rotate, for instance. {Điều này cũng mở khóa transition và keyframes riêng cho từng property — ví dụ duration khác nhau cho scale và rotate.}
Stepping into 3D
3D transforms add a Z axis pointing out of the screen toward the viewer. {Transform 3D thêm trục Z hướng ra khỏi màn hình về phía người xem.} To see depth, you need perspective — without it, 3D rotations look flat. {Để thấy chiều sâu, bạn cần perspective — thiếu nó, các phép xoay 3D trông phẳng lì.}
perspective and perspective-origin
perspective sets how far the viewer is from the Z=0 plane. {perspective đặt khoảng cách từ người xem đến mặt phẳng Z=0.} Smaller values = stronger, more dramatic distortion. {Giá trị nhỏ = méo mạnh, kịch tính hơn.}
/* Option 1: perspective on the PARENT applies to all 3D children. */
.scene {
perspective: 800px;
perspective-origin: 50% 50%; /* vanishing point */
}
/* Option 2: the perspective() function inside a single element's transform. */
.lonely-card {
transform: perspective(800px) rotateY(45deg);
}
Use the parent perspective when several children should share one consistent vanishing point. {Dùng perspective ở cha khi nhiều con cần chung một điểm tụ nhất quán.} Use the perspective() function for a one-off isolated element. {Dùng hàm perspective() cho một element riêng lẻ độc lập.}
The 3D rotation and translation functions
.r {
rotateX: 45deg; /* tilt forward/back, around the horizontal axis */
/* shorthand forms inside transform: */
transform: rotateX(45deg); /* pitch */
transform: rotateY(45deg); /* yaw — the classic flip */
transform: rotateZ(45deg); /* roll — same as 2D rotate() */
transform: translateZ(50px); /* push toward / away from viewer */
}
translateZ only produces a visible effect when a perspective is active — otherwise moving along Z changes nothing on a flat projection. {translateZ chỉ tạo hiệu ứng thấy được khi perspective đang bật — nếu không, dịch theo Z chẳng thay đổi gì trên phép chiếu phẳng.}
transform-style: preserve-3d
By default children are flattened into the parent’s plane. {Mặc định các con bị làm phẳng vào mặt phẳng của cha.} transform-style: preserve-3d tells the browser to keep children positioned in true 3D space. {transform-style: preserve-3d bảo trình duyệt giữ các con ở đúng không gian 3D.} This is mandatory for nested 3D scenes and card flips. {Đây là bắt buộc cho scene 3D lồng nhau và card flip.}
.cube {
transform-style: preserve-3d; /* children live in 3D, not flattened */
}
backface-visibility
When a face rotates more than 90°, you see its back. {Khi một mặt xoay quá 90°, bạn nhìn thấy mặt sau của nó.} backface-visibility: hidden hides that mirrored back side — essential so a card’s reverse face doesn’t bleed through. {backface-visibility: hidden ẩn mặt sau bị lật gương đó — thiết yếu để mặt sau của thẻ không lòi qua.}
.face {
backface-visibility: hidden; /* hide the mirrored back of each face */
}
Building a 3D card flip
Now we combine everything: a scene with perspective, a rotating inner container with preserve-3d, and two faces with backface-visibility: hidden. {Giờ ghép tất cả lại: một scene có perspective, một container bên trong xoay với preserve-3d, và hai mặt với backface-visibility: hidden.}
<div class="card-scene">
<div class="card">
<div class="card-face card-front">Front</div>
<div class="card-face card-back">Back</div>
</div>
</div>
.card-scene {
/* The viewport into 3D space; perspective lives on the parent. */
perspective: 1000px;
width: 240px;
height: 320px;
}
.card {
position: relative;
width: 100%;
height: 100%;
/* Keep both faces in real 3D so the back can sit behind the front. */
transform-style: preserve-3d;
transition: transform 0.6s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Flip on hover (or toggle a class via JS for click/tap). */
.card-scene:hover .card {
transform: rotateY(180deg);
}
.card-face {
position: absolute;
inset: 0;
display: grid;
place-items: center;
border-radius: 12px;
/* Hide the mirrored reverse of each face. */
backface-visibility: hidden;
}
.card-front {
background: #1a1a1a;
}
.card-back {
background: #c8ff00;
color: #000;
/* Pre-rotate the back so it faces away until the flip completes. */
transform: rotateY(180deg);
}
The trick: the back face is pre-rotated 180°, so it starts facing away. {Mẹo: mặt sau được xoay sẵn 180°, nên ban đầu nó quay đi.} When .card rotates 180°, the back swings to face you while the front turns away. {Khi .card xoay 180°, mặt sau quay về phía bạn còn mặt trước quay đi.} backface-visibility: hidden ensures only the currently-facing side is painted. {backface-visibility: hidden đảm bảo chỉ mặt đang hướng về bạn được vẽ.}
For a click-driven flip instead of hover, toggle a class with a few lines of JS. {Để flip bằng click thay vì hover, toggle một class với vài dòng JS.}
const card = document.querySelector('.card');
card.addEventListener('click', () => {
card.classList.toggle('is-flipped');
});
/* Then drive the flip from the class instead of :hover. */
.card.is-flipped {
transform: rotateY(180deg);
}
Why transform + opacity is the compositor win
This is the part senior engineers care about most. {Đây là phần kỹ sư senior quan tâm nhất.} Browsers render in stages, often called the pixel pipeline: {Trình duyệt render theo các giai đoạn, thường gọi là pixel pipeline:}
- Layout — compute box geometry (position, size). {Layout — tính hình học box (vị trí, kích thước).}
- Paint — fill pixels (colors, text, shadows). {Paint — tô pixel (màu, chữ, shadow).}
- Composite — assemble painted layers onto the screen, possibly on the GPU. {Composite — ghép các layer đã paint lên màn hình, có thể trên GPU.}
Animating top, left, width, or height changes geometry, so the browser must re-run layout → paint → composite on every frame. {Animate top, left, width, hay height làm đổi hình học, nên trình duyệt phải chạy lại layout → paint → composite mỗi frame.} At 60fps that is a lot of work in a 16ms budget, and it commonly drops frames. {Ở 60fps đó là khối lượng lớn trong ngân sách 16ms, và thường rớt frame.}
/* ❌ Janky: every frame triggers layout + paint for the whole subtree. */
.bad {
position: relative;
left: 0;
transition: left 0.3s ease;
}
.bad:hover {
left: 100px;
}
transform and opacity are special: they can be handled entirely in the composite stage, on the GPU, skipping layout and paint. {transform và opacity đặc biệt: chúng có thể xử lý hoàn toàn ở giai đoạn composite, trên GPU, bỏ qua layout và paint.} The element is already painted to a layer; the compositor just moves, scales, or fades that texture. {Element đã được paint thành một layer; compositor chỉ việc dịch, scale, hoặc làm mờ texture đó.}
/* ✅ Smooth: only the composite stage runs each frame. */
.good {
transform: translateX(0);
transition: transform 0.3s ease;
}
.good:hover {
transform: translateX(100px);
}
will-change — promote a layer, sparingly
will-change hints the browser to promote an element to its own compositor layer ahead of time. {will-change gợi ý trình duyệt thăng element lên layer compositor riêng từ trước.} Use it right before an animation, not globally. {Dùng nó ngay trước animation, không phải toàn cục.}
.menu {
/* Tell the browser a transform animation is coming. */
will-change: transform;
}
Overusing will-change creates too many layers and eats memory, which can hurt performance instead of helping. {Lạm dụng will-change tạo quá nhiều layer và ngốn bộ nhớ, có thể làm hại hiệu năng thay vì giúp.} Apply it to the few elements that genuinely animate, and consider removing it after the animation ends. {Chỉ áp dụng cho vài element thực sự animate, và cân nhắc gỡ bỏ sau khi animation kết thúc.}
Mental model for choosing properties
A simple heuristic: if a property can change without moving any other element, it is likely compositor-friendly. {Một heuristic đơn giản: nếu một property thay đổi mà không làm dịch element nào khác, nó nhiều khả năng thân thiện với compositor.} transform and opacity qualify; geometry properties do not. {transform và opacity đạt điều đó; các property hình học thì không.}
- Need to move? Use
translate, nottop/left. {Cần di chuyển? Dùngtranslate, khôngtop/left.} - Need to resize? Use
scale, notwidth/height. {Cần đổi kích thước? Dùngscale, khôngwidth/height.} - Need to fade? Use
opacity. {Cần làm mờ? Dùngopacity.}
One caveat: scale stretches painted content (including text), so it can look slightly soft mid-animation; that is usually an acceptable trade for smoothness. {Một lưu ý: scale kéo giãn nội dung đã paint (kể cả chữ), nên có thể hơi mờ giữa animation; thường đây là đánh đổi chấp nhận được để mượt.}
Wrap-up
Transforms are cheap because they sidestep layout and paint, letting the GPU compositor do the work. {Transform rẻ vì nó né layout và paint, để GPU compositor làm việc.} Master the 2D primitives, respect that order matters, adopt the individual translate/rotate/scale properties for clean composition, and reach for 3D with perspective + preserve-3d + backface-visibility when you need depth. {Nắm vững các primitive 2D, tôn trọng việc thứ tự quan trọng, dùng các property translate/rotate/scale độc lập để ghép sạch sẽ, và dùng 3D với perspective + preserve-3d + backface-visibility khi cần chiều sâu.}
Above all: animate transform and opacity, and your interfaces will stay buttery at 60fps. {Trên hết: animate transform và opacity, giao diện của bạn sẽ mượt như bơ ở 60fps.}