jvinhit//lab

Search posts

Type to search across journal entries.

navigate open esc close

CSS 3D from Scratch · Part 3 — Interactive Tilt & Parallax

Make 3D respond to the human: a card that tilts toward the cursor in real time, layered parallax depth with translateZ, a moving glare highlight, and the will-change performance trick. Beginner-friendly, with a live tilt demo.

9 MIN READ

So far, padawan, your 3D has been admired from a distance — a flip here, a cube there. {Tới giờ, đồ đệ, 3D của con mới chỉ được ngắm từ xa — một cú flip ở đây, một cube ở kia.} Today it reaches out and touches the user back. {Hôm nay nó vươn ra và chạm lại người dùng.} We build the effect you’ve seen on every fancy product page: a card that leans toward your cursor like it’s alive, with inner layers floating at different depths. {Ta dựng hiệu ứng con đã thấy trên mọi trang sản phẩm sang chảnh: một tấm card nghiêng về phía con trỏ như có sự sống, với các lớp bên trong lơ lửng ở những độ sâu khác nhau.}

The master will be honest: this one needs a pinch of JavaScript. {Sư phụ nói thật: cái này cần một nhúm JavaScript.} CSS can’t read your mouse position. {CSS không đọc được vị trí chuột.} But the JS is tiny — maybe 12 lines — and CSS still does all the heavy 3D lifting. {Nhưng JS rất nhỏ — chừng 12 dòng — và CSS vẫn gánh toàn bộ phần 3D nặng nhọc.}

Move your mouse over the card. Then tune max tilt, perspective, and layer depth. {Di chuột lên card. Rồi chỉnh max tilt, perspective, và layer depth.}

Open the full demo {Mở demo đầy đủ}: /tools/css-3d-tilt-demo/.

The recipe {Công thức}

Every mouse-tilt card is the same three steps: {Mọi card nghiêng theo chuột đều là ba bước:}

  1. Put perspective on the container (the camera — Part 1). {Đặt perspective lên container (máy ảnh — Phần 1).}
  2. In JS, read where the cursor is inside the card (a 0→1 ratio). {Trong JS, đọc vị trí con trỏ bên trong card (tỉ lệ 0→1).}
  3. Map that ratio to rotateX/rotateY and write it to the card’s transform. {Ánh xạ tỉ lệ đó sang rotateX/rotateY và ghi vào transform của card.}
<div class="stage">         <!-- the camera -->
  <div class="card">        <!-- the thing that tilts -->
    <div class="layer">…</div>
  </div>
</div>
.stage { perspective: 800px; }
.card {
  transform-style: preserve-3d;     /* so inner layers get real depth */
  transition: transform 0.12s ease-out;  /* smooth, and snaps back on leave */
}

The 12 lines of JavaScript {12 dòng JavaScript}

Here’s the entire brain of the effect. {Đây là toàn bộ bộ não của hiệu ứng.} Read the comments — the math is just “where is the mouse, from 0 to 1?” {Đọc comment — toán chỉ là “chuột đang ở đâu, từ 0 đến 1?”}

const stage = document.querySelector('.stage');
const card = document.querySelector('.card');
const MAX = 14; // degrees of tilt at the edges

stage.addEventListener('pointermove', (e) => {
  const r = card.getBoundingClientRect();
  const px = (e.clientX - r.left) / r.width;   // 0 (left) → 1 (right)
  const py = (e.clientY - r.top) / r.height;   // 0 (top)  → 1 (bottom)

  const ry = (px - 0.5) * 2 * MAX;   // -MAX → +MAX as cursor goes left→right
  const rx = -(py - 0.5) * 2 * MAX;  // +MAX → -MAX as cursor goes top→bottom

  card.style.transform = `rotateX(${rx}deg) rotateY(${ry}deg)`;
});

stage.addEventListener('pointerleave', () => {
  card.style.transform = 'rotateX(0deg) rotateY(0deg)'; // snap back flat
});

Two subtleties the master wants you to notice: {Hai điểm tinh tế sư phụ muốn con để ý:}

  • We subtract 0.5 so the center is neutral (0°) and the edges are full tilt. {Ta trừ 0.5 để tâm là trung tính (0°) còn các cạnh là nghiêng tối đa.}
  • rotateX is negated. When the cursor is at the bottom, you want the bottom edge to come toward you — which is a negative rotateX. Flip this sign and the card tilts the “wrong” way (which, sometimes, looks cool too — try the direction toggle). {rotateX bị đảo dấu. Khi con trỏ ở dưới, con muốn cạnh dưới tiến về phía con — đó là rotateX âm. Đảo dấu này thì card nghiêng “ngược” (đôi khi cũng đẹp — thử nút direction).}

We use pointermove (not mousemove) so it works for mouse, pen, and touch with one handler. {Ta dùng pointermove (không phải mousemove) để chạy cho chuột, bút, và cảm ứng với một handler.}

Parallax: the part that sells it {Parallax: phần làm nên đẳng cấp}

A flat card that tilts is fine. {Một card phẳng mà nghiêng thì tạm ổn.} A card whose insides move at different speeds is magic. {Một card mà bên trong di chuyển ở tốc độ khác nhau mới là phép thuật.} That’s parallax, and you already have everything you need: translateZ. {Đó là parallax, và con đã có đủ mọi thứ cần: translateZ.}

Give each inner layer a different Z height and let the parent tilt carry them: {Cho mỗi lớp bên trong một độ cao Z khác nhau và để cha nghiêng mang chúng theo:}

.card { transform-style: preserve-3d; }   /* required, or layers flatten */

.layer.badge { transform: translateZ(70px); }  /* floats high — moves a LOT */
.layer.title { transform: translateZ(40px); }
.layer.chip  { transform: translateZ(55px); }
.layer.foot  { transform: translateZ(20px); }  /* near the surface — barely moves */

Why does this create parallax for free? {Vì sao nó tạo parallax miễn phí?} Because when the parent rotates, a point sitting 70px above the surface swings through a much bigger arc than a point at 20px. {Vì khi cha xoay, một điểm nằm cao 70px so với bề mặt quét qua một cung lớn hơn nhiều so với điểm ở 20px.} Higher layers travel farther on screen — exactly how near and far objects behave in real life. {Lớp cao hơn di chuyển xa hơn trên màn hình — đúng như cách vật gần và xa hành xử ngoài đời.}

Drag layer depth × in the demo from 1 down to 0. {Kéo layer depth × trong demo từ 1 xuống 0.} At 0, every layer flattens to the surface and the card goes lifeless. {Ở 0, mọi lớp dẹp về bề mặt và card trở nên vô hồn.} That single slider is the difference between “nice” and “whoa”. {Cái slider đó chính là khác biệt giữa “đẹp” và “wow”.}

The glare — fake light, real polish {Glare — ánh sáng giả, độ bóng thật}

A moving highlight that follows the cursor tricks the eye into seeing a glossy surface catching light. {Một vệt sáng di chuyển theo con trỏ đánh lừa mắt thấy một bề mặt bóng đang bắt sáng.} It’s just a radial gradient whose center we move with a CSS variable: {Nó chỉ là một radial gradient mà ta dời tâm bằng một biến CSS:}

.glare {
  position: absolute; inset: 0; pointer-events: none;
  background: radial-gradient(
    circle at var(--gx, 50%) var(--gy, 50%),
    rgba(255,255,255,0.28), transparent 45%
  );
  transform: translateZ(80px); /* sit it above the other layers */
}
glare.style.setProperty('--gx', `${px * 100}%`);
glare.style.setProperty('--gy', `${py * 100}%`);

We’ll do proper directional shading in Part 5. {Ta sẽ làm shading theo hướng đúng nghĩa ở Phần 5.} This is the cheap, delightful version. {Đây là bản rẻ tiền mà vui mắt.}

will-change — the performance trick {will-change — mẹo hiệu năng}

When something tilts continuously, hint the browser to promote it to its own GPU layer ahead of time: {Khi một thứ nghiêng liên tục, hãy gợi ý trình duyệt nâng nó lên một lớp GPU riêng trước:}

.card { will-change: transform; }

This avoids a hitch on the first move. {Cái này tránh giật ở lần di chuyển đầu.} But heed the master’s warning — will-change is a scalpel, not a seasoning: {Nhưng nghe lời cảnh báo của sư phụ — will-change là dao mổ, không phải gia vị:}

Don’t slap will-change on everything. Each one costs GPU memory. Use it only on elements that are actually about to animate, and ideally add/remove it around the interaction. {Đừng phết will-change lên mọi thứ. Mỗi cái tốn bộ nhớ GPU. Chỉ dùng trên phần tử thực sự sắp animate, và lý tưởng là thêm/bỏ nó quanh tương tác.}

The master’s warnings {Lời cảnh báo của sư phụ}

  • Forgot preserve-3d on the card? Your parallax layers will flatten and only the tilt remains. {Quên preserve-3d trên card? Các lớp parallax sẽ dẹp phẳng và chỉ còn lại cái nghiêng.}
  • Reading the wrong rect. Measure the card’s getBoundingClientRect(), not the window — otherwise the neutral center won’t line up with the card. {Đọc sai rect. Đo getBoundingClientRect() của card, không phải cửa sổ — nếu không tâm trung tính sẽ lệch khỏi card.}
  • No reset on leave. Without the pointerleave reset, the card freezes mid-tilt when the cursor leaves. Always snap back. {Không reset khi rời. Không có reset pointerleave, card đứng hình giữa chừng khi con trỏ rời đi. Luôn bật về phẳng.}
  • Respect reduced motion. Some users get dizzy. Wrap the tilt in a check (see Part 5) and simply skip it for them. {Tôn trọng reduced motion. Một số người bị chóng mặt. Bọc cái nghiêng trong một kiểm tra (xem Phần 5) và đơn giản là bỏ qua cho họ.}

Practice, or it didn’t happen {Luyện tập, không thì coi như chưa học}

  1. Build your own tilt card {Tự dựng card nghiêng}: start from a plain <div> with a title and a button, add the 12 lines, and get it leaning toward your cursor. {bắt đầu từ một <div> đơn giản có tiêu đề và nút, thêm 12 dòng, và làm nó nghiêng về con trỏ.}
  2. Add three parallax layers {Thêm ba lớp parallax}: put an icon, a title, and a footer at translateZ of 80, 40, and 15px. Feel the depth appear. {đặt một icon, tiêu đề, và footer ở translateZ 80, 40, và 15px. Cảm nhận chiều sâu hiện ra.}
  3. Smooth it out {Làm mượt}: instead of writing the transform directly, lerp toward the target each frame with requestAnimationFrame for a buttery, weighted feel. {thay vì ghi transform trực tiếp, hãy nội suy (lerp) về mục tiêu mỗi khung hình bằng requestAnimationFrame cho cảm giác mượt như bơ, có quán tính.}
  4. A whole grid {Cả một lưới}: make a 3×3 grid of tilt cards. (Lesson: give each its own perspective container, or share one for a unified scene — try both.) {làm lưới 3×3 card nghiêng. (Bài học: cho mỗi cái một container perspective riêng, hoặc chung một cái cho khung cảnh thống nhất — thử cả hai.)}

What’s next {Phần tiếp theo}

You can now make 3D respond to a human in real time, with convincing layered depth and a moving highlight. {Giờ con làm được 3D phản hồi con người theo thời gian thực, với chiều sâu phân lớp thuyết phục và một vệt sáng di động.} That’s the technique behind countless “premium” UIs. {Đó là kỹ thuật đứng sau vô số UI “cao cấp”.}

In Part 4, we set objects in motion on their own — pure-CSS 3D animations: a spinning showcase, and the crown jewel, a 3D carousel / coverflow built from nothing but rotateY and translateZ. {Ở Phần 4, ta cho vật thể tự chuyển động — animation 3D thuần CSS: một showcase xoay tròn, và viên ngọc quý, một carousel / coverflow 3D dựng từ đúng rotateYtranslateZ.}