jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

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:}

  1. Layout — compute box geometry (position, size). {Layout — tính hình học box (vị trí, kích thước).}
  2. Paint — fill pixels (colors, text, shadows). {Paint — tô pixel (màu, chữ, shadow).}
  3. 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. {transformopacity đặ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. {transformopacity đạt điều đó; các property hình học thì không.}

  • Need to move? Use translate, not top/left. {Cần di chuyển? Dùng translate, không top/left.}
  • Need to resize? Use scale, not width/height. {Cần đổi kích thước? Dùng scale, không width/height.}
  • Need to fade? Use opacity. {Cần làm mờ? Dùng opacity.}

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 transformopacity, giao diện của bạn sẽ mượt như bơ ở 60fps.}