CSS 3D from Scratch · Part 4 — 3D Motion & Carousels
Set 3D objects in motion: pure-CSS keyframe spins, arranging cards in a ring with rotateY and translateZ, building a real 3D carousel with prev/next and auto-rotate, and the idea behind Apple-style coverflow. With a live demo.
Your objects can stand, padawan — now teach them to dance. {Vật thể của con đã đứng được, đồ đệ — giờ dạy chúng nhảy múa.} In this lesson we put 3D in motion, and we build the piece that makes people say “you did that in CSS?”: a real 3D carousel. {Bài này ta cho 3D chuyển động, và dựng thứ khiến người ta thốt lên “làm cái đó bằng CSS á?”: một carousel 3D thật.}
Here’s the beautiful secret: a carousel is just the cube from Part 2 with more faces, bent into a circle. {Đây là bí mật đẹp đẽ: carousel chính là cái cube ở Phần 2 với nhiều mặt hơn, uốn thành vòng tròn.} If you understood the cube, you already understand this. {Nếu con hiểu cube, con đã hiểu cái này rồi.}
Spin it with Prev/Next (or arrow keys), then change the item count and radius. {Quay nó bằng Prev/Next (hoặc phím mũi tên), rồi đổi số item và bán kính.}
Open the full demo {Mở demo đầy đủ}: /tools/css-3d-carousel-demo/.
First, motion the easy way: keyframes {Trước hết, chuyển động cách dễ: keyframes}
You met this with the cube. Any 3D transform can be animated like any other CSS value: {Con đã gặp ở cube. Mọi transform 3D đều animate được như mọi giá trị CSS khác:}
@keyframes orbit {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
.showcase {
transform-style: preserve-3d; /* keep the depth while spinning */
animation: orbit 8s linear infinite;
}
Two reminders from earlier parts that matter doubly in motion: {Hai nhắc nhở từ các phần trước, càng quan trọng khi chuyển động:}
- The parent needs
preserve-3d, or the spin flattens into a 2D pancake. {Cha cầnpreserve-3d, không thì cú xoay dẹp thành bánh kếp 2D.} - Animate only
transform(andopacity) for smooth, GPU-driven motion. Never animatetop/left/width(Part 5 covers why in depth). {Chỉ animatetransform(vàopacity) để mượt, do GPU lo. Đừng animatetop/left/width(Phần 5 giải thích sâu).}
The carousel: arranging cards in a ring {Carousel: xếp card thành vòng}
Now the main course. {Giờ tới món chính.} We want N cards evenly spaced around a circle, all facing outward. {Ta muốn N card cách đều quanh một vòng tròn, tất cả quay mặt ra ngoài.} The recipe is the cube’s recipe — rotate to aim, translateZ to push out — but the angle is now 360° ÷ N instead of fixed 90°. {Công thức là công thức của cube — xoay để nhắm, translateZ để đẩy ra — nhưng góc giờ là 360° ÷ N thay vì cố định 90°.}
<div class="stage"> <!-- camera -->
<div class="ring"> <!-- the rotating circle -->
<div class="ring-card">1</div>
<div class="ring-card">2</div>
<!-- ...N cards... -->
</div>
</div>
.stage { perspective: 1100px; }
.ring {
position: relative;
transform-style: preserve-3d;
transition: transform 0.7s cubic-bezier(0.4, 0, 0.2, 1); /* smooth slot changes */
}
.ring-card { position: absolute; inset: 0; }
Each card is placed with a tiny bit of JS (because the angle depends on how many there are): {Mỗi card được đặt bằng một chút JS (vì góc phụ thuộc vào có bao nhiêu cái):}
const n = 6;
const angle = 360 / n; // 60° between cards
const radius = 260; // how far from the center
cards.forEach((card, i) => {
// Aim card i around the circle, then push it out to the rim.
card.style.transform = `rotateY(${i * angle}deg) translateZ(${radius}px)`;
});
Read card i’s transform like the cube faces: rotate its local space by i × angle, then translateZ pushes it out along its own rotated Z — so it lands on the rim, facing outward. {Đọc transform của card i như các mặt cube: xoay không gian cục bộ của nó i × góc, rồi translateZ đẩy ra dọc theo Z của chính nó đã xoay — nên nó đáp lên vành, quay mặt ra ngoài.} Card 0 faces front, card 1 is 60° around, and so on. {Card 0 quay ra trước, card 1 ở 60° vòng quanh, cứ thế.}
Bringing a card to the front {Đưa một card ra trước}
To show card k, rotate the whole ring the opposite way by k × angle: {Để hiện card k, xoay cả vòng theo chiều ngược lại k × góc:}
ring.style.transform = `translateZ(-${radius}px) rotateY(${-k * angle}deg)`;
Two things happen here, both important: {Có hai chuyện xảy ra, đều quan trọng:}
rotateY(-k × angle)spins the ring so cardkswings to the front. {rotateY(-k × góc)xoay vòng để cardkquay ra trước.}translateZ(-radius)pushes the whole ring back by the radius, so the front card sits near the camera plane instead of floatingradiuspixels in front of everything. {translateZ(-bán kính)đẩy cả vòng lùi đúng bằng bán kính, để card trước nằm gần mặt phẳng máy ảnh thay vì lơ lửngbán kínhpixel trước mọi thứ.}
Because .ring has a transition, every slot change glides. {Vì .ring có transition, mỗi lần đổi slot đều trôi mượt.} Prev/Next is just k-- / k++: {Prev/Next chỉ là k-- / k++:}
nextBtn.onclick = () => { k++; show(k); };
prevBtn.onclick = () => { k--; show(k); };
Notice k is allowed to grow past n or go negative — the ring keeps spinning the same direction forever, which feels more natural than snapping back. {Để ý k được phép vượt quá n hoặc âm — vòng cứ quay cùng một chiều mãi, cảm giác tự nhiên hơn là giật ngược.} Use modulo only to decide which card is “active” for highlighting. {Chỉ dùng phép chia lấy dư để quyết định card nào đang “active” để tô sáng.}
Play with the items and radius sliders. {Nghịch slider items và radius.} More items need a bigger radius or they overlap; fewer items with a big radius float far apart. {Nhiều item cần bán kính lớn hơn không thì chồng lên nhau; ít item với bán kính lớn thì cách xa nhau.}
Coverflow — the Apple classic {Coverflow — kinh điển nhà Apple}
Coverflow is a flat cousin of the ring: instead of a full circle, items sit in a row, and the ones off-center are rotated to angle away like an open book. {Coverflow là anh em “phẳng” của vòng tròn: thay vì vòng tròn đầy đủ, các item nằm thành một hàng, và những cái lệch tâm bị xoay nghiêng ra như cuốn sách mở.}
/* center item faces you; side items angle away and sit slightly back */
.cover.left { transform: translateX(-120px) rotateY(45deg) translateZ(-80px); }
.cover.center { transform: translateX(0) rotateY(0deg) translateZ(0); }
.cover.right { transform: translateX(120px) rotateY(-45deg) translateZ(-80px); }
Same primitives — translateX, rotateY, translateZ — arranged in a line instead of a circle. {Cùng các primitive — translateX, rotateY, translateZ — xếp thành đường thẳng thay vì vòng tròn.} Once you see that carousels, coverflow, and the cube are all “rotate + translateZ in a pattern”, you can invent your own arrangements: helixes, fans, walls. {Một khi con thấy carousel, coverflow, và cube đều là “rotate + translateZ theo một quy luật”, con có thể tự sáng tạo cách sắp xếp riêng: xoắn ốc, hình quạt, bức tường.}
The master’s warnings {Lời cảnh báo của sư phụ}
- Don’t animate
left/marginto move cards. Usetransformso motion stays on the GPU. A carousel that animates layout will jank on phones. {Đừng animateleft/marginđể dời card. Dùngtransformđể chuyển động ở lại trên GPU. Carousel animate layout sẽ giật trên điện thoại.} - Mind
backface-visibility. Back-facing cards in a ring show their backs. Hide them (hidden) unless you designed a back. {Để ýbackface-visibility. Card quay lưng trong vòng sẽ lộ mặt sau. Ẩn chúng (hidden) trừ khi con có thiết kế mặt sau.} - Overflow clips the magic. If a parent has
overflow: hidden, it can flattenpreserve-3d(Part 2). Give the 3D ring room to breathe. {Overflow cắt mất phép thuật. Nếu cha cóoverflow: hidden, nó có thể dẹppreserve-3d(Phần 2). Cho vòng 3D không gian thở.} - Keep it keyboard-friendly. A carousel that only responds to drag excludes keyboard users — wire arrow keys and real
<button>s, like the demo. {Giữ thân thiện bàn phím. Carousel chỉ phản hồi kéo sẽ loại người dùng bàn phím — đấu phím mũi tên và<button>thật, như demo.}
Practice, or it didn’t happen {Luyện tập, không thì coi như chưa học}
- Build the ring {Dựng vòng}: place 6 cards with
rotateY(i*60deg) translateZ(260px)and wire Prev/Next. Get one card cleanly in front. {đặt 6 card vớirotateY(i*60deg) translateZ(260px)và đấu Prev/Next. Đưa một card ra trước gọn gàng.} - Make it auto-play {Cho tự chạy}: advance every 1.4s with
setInterval, and pause on hover/focus. {tự tiến mỗi 1.4s bằngsetInterval, và dừng khi hover/focus.} - Compute the radius {Tính bán kính}: instead of guessing, derive a radius that fits N cards of width
wwith no overlap:radius = (w/2) / tan(180°/N). {thay vì đoán, tính bán kính vừa cho N card rộngwkhông chồng:radius = (w/2) / tan(180°/N).} - Build a coverflow {Dựng coverflow}: lay 5 covers in a row, rotate the side ones, and slide the row left/right to change the centered item. {xếp 5 cover thành hàng, xoay các cái bên, và trượt hàng trái/phải để đổi item trung tâm.}
What’s next {Phần tiếp theo}
You can now animate 3D and build the showpiece interactions — spinning showcases, rings, and coverflow — all from rotateY + translateZ. {Giờ con animate được 3D và dựng được các tương tác đỉnh — showcase xoay, vòng, và coverflow — tất cả từ rotateY + translateZ.}
In Part 5, the final lesson, we make it production-ready: fake lighting and shading so objects look truly solid, then the unglamorous-but-essential craft — performance (compositor, will-change, z-fighting), accessibility (prefers-reduced-motion), graceful fallbacks, and how to debug 3D when it goes wrong. {Ở Phần 5, bài cuối, ta làm cho nó sẵn sàng production: ánh sáng và shading giả để vật thể trông thật sự đặc, rồi phần nghề không hào nhoáng-nhưng-thiết-yếu — hiệu năng, khả năng tiếp cận, fallback duyên dáng, và cách debug 3D khi nó trục trặc.}