9 CSS 3D Cube Effects — dán 1 dòng là chạy
CSS 3D cube là nhóm hiệu ứng khối lập phương xoay, carousel hình trụ và card nghiêng khi hover, dựng bằng perspective và transform thuần CSS, không dùng
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Xoay cube 3D
- 02 Carousel 3D
- 03 Nghiêng khi hover
- 04 Hiệu ứng mở sách
- 05 Gallery lật thẻ
- 06 Coverflow
- 07 Sân khấu xoay
- 08 Chữ nổi 3D
- 09 Cuộn phòng 3D
Chín hiệu ứng xếp theo cách chúng dùng ba thuộc tính dựng chiều sâu — perspective (khoảng cách camera), transform-style: preserve-3d (giữ phần tử con khỏi bị ép phẳng) và backface-visibility (ẩn mặt sau khi xoay). Ba mục đầu (01–03) dùng các thuộc tính này ở dạng đơn giản nhất — một khối, một vòng, một thẻ. Ba mục giữa (04–06) xếp nhiều lớp hoặc nhiều phần tử chồng nhau, còn ba mục cuối (07–09) dựng nguyên một sân khấu hoặc căn phòng thành không gian 3D. Mỗi mục dưới đây chỉ trích đoạn SCSS cốt lõi, bản đầy đủ nằm trong file nén.
01Xoay cube 3D
Khối lập phương 4 mặt xoay liên tục quanh trục Y bên trong perspective của phần tử cha, mỗi mặt hiện một nhãn khác nhau lần lượt khi quay tới. Hợp với cube giới thiệu sản phẩm ở hero landing hoặc banner đổi thông tin theo vòng.
.cube {
transform-style: preserve-3d;
animation: spin $duration $easing infinite;
}
.cube__face {
position: absolute; inset: 0;
backface-visibility: hidden;
}
.cube__face--front { transform: translateZ($half); }
.cube__face--right { transform: rotateY(90deg) translateZ($half); }
.cube__face--back { transform: rotateY(180deg) translateZ($half); }
.cube__face--left { transform: rotateY(-90deg) translateZ($half); }
@keyframes spin {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
02Carousel 3D
5 thẻ được đặt cố định quanh vòng tròn bằng rotateY, chỉ riêng ring cha xoay liên tục. Khác với marquee ở bài 028 đẩy track bằng translateX, ở đây không hề dùng translateX — toàn bộ chuyển động chỉ đến từ một phép xoay của phần tử cha.
.ring {
transform-style: preserve-3d;
animation: orbit $duration $easing infinite;
}
// Đặt 5 thẻ quanh vòng tròn cách nhau 72deg — phải nhân trước ở Sass
// để không ra CSS vô hiệu kiểu rotateY(0 * 72deg)
@for $i from 1 through 5 {
.ring__card:nth-child(#{$i}) {
transform: rotateY(#{($i - 1) * $step}) translateZ($radius);
}
}
@keyframes orbit {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
03Nghiêng khi hover
Card nghiêng đúng một góc cố định khi hover. Khác với parallax tilt 3D ở bài 032 đọc toạ độ chuột bằng JS mỗi frame, đây chỉ transition một giá trị :hover cố định, JS 0 dòng.
.frame { perspective: 600px; }
.tilt {
transform: rotateX(0deg) rotateY(0deg);
transition: transform $duration $easing;
}
.tilt:hover,
.tilt:focus-visible,
.tilt:active {
transform: rotateX(10deg) rotateY(-14deg);
}
@media (hover: none) {
.tilt:active { transform: rotateX(10deg) rotateY(-14deg); }
}
04Hiệu ứng mở sách
Bìa mở ra -150deg quanh bản lề bên trái để lộ trang bên trong. Flip card ở bài 006 lật nguyên thẻ 180deg quanh trục giữa, còn ở đây chỉ một cạnh làm bản lề và không lật hết.
.book__cover {
transform-style: preserve-3d;
transform-origin: left center;
animation: open $duration $easing infinite;
}
.book__face {
position: absolute; inset: 0;
backface-visibility: hidden;
}
.book__face--front { background: $color; }
.book__face--back { background: darken($color, 14%); transform: rotateY(180deg); }
@keyframes open {
0%, 15% { transform: rotateY(0deg); }
55% { transform: rotateY(-150deg); }
85%, 100% { transform: rotateY(0deg); }
}
05Gallery lật thẻ
4 ô lưới lật lần lượt, mỗi ô trễ 0,15 giây so với ô trước, để lộ nhãn danh mục phía sau. Hợp với lưới danh mục trong gallery media hoặc giới thiệu widget dashboard.
.tile__inner {
transform-style: preserve-3d;
}
.tile__face {
position: absolute; inset: 0;
backface-visibility: hidden;
}
.tile__face--back { transform: rotateY(180deg); }
.tile:nth-child(1) .tile__inner { animation: flip $duration $easing infinite; }
.tile:nth-child(2) .tile__inner { animation: flip $duration $easing infinite; animation-delay: .15s; }
@keyframes flip {
0%, 15% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
85%, 100% { transform: rotateY(360deg); }
}
06Coverflow
5 thẻ dùng chung một keyframes nhưng lệch nhau bằng animation-delay âm, nên thẻ giữa to và thẳng mặt còn hai bên nhỏ và nghiêng dần ra xa. Toàn bộ chu kỳ chạy chỉ bằng CSS, JS 0 dòng.
.flow__card {
animation: flow $duration $easing infinite;
animation-delay: calc(var(--i) * -0.4s);
}
@keyframes flow {
0% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); }
40% { transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); }
80% { transform: translateX(130px) translateZ(-90px) rotateY(-45deg) scale(.6); }
100% { transform: translateX(-130px) translateZ(-90px) rotateY(45deg) scale(.6); }
}
07Sân khấu xoay
Vật thể xoay chậm một vòng mỗi 5 giây trên sàn hình elip nghiêng bên dưới. Chậm hơn cube xoay ở mục 01 và có thêm sàn nên trông như một bàn xoay turntable.
.turntable__floor {
border-radius: 50%;
transform: translateX(-50%) rotateX(70deg);
}
.turntable__object {
transform-style: preserve-3d;
animation: turn $duration $easing infinite;
}
.turntable__face {
position: absolute; inset: 0;
backface-visibility: hidden;
}
.turntable__face--back { transform: rotateY(180deg); }
@keyframes turn {
from { transform: rotateY(0deg); }
to { transform: rotateY(360deg); }
}
08Chữ nổi 3D
8 lớp text-shadow cùng màu, mỗi lớp đậm hơn một chút, tạo độ dày cho chữ trong khi rotateX xoay cả tiêu đề quanh cạnh dưới rồi quay lại. Hợp với tiêu đề hero landing hoặc tên sự kiện trên banner.
@for $i from 1 through 8 {
$out: list.append($out, #{$i}px #{$i}px darken($color, $i * 4%), comma);
}
.sign {
text-shadow: $out;
transform-origin: center bottom;
animation: tilt $duration $easing infinite;
}
@keyframes tilt {
0%, 20% { transform: rotateX(0deg); }
55% { transform: rotateX(-34deg); }
100% { transform: rotateX(0deg); }
}
09Cuộn phòng 3D
Một căn phòng không có tường mặt trước — ba tường và một sàn — xoay đúng một vòng mỗi 9 giây dưới perspective ngắn, khiến camera như đang đứng trong phòng nhìn quanh. Cube 4 mặt ở mục 01 nhìn từ ngoài, còn đây là cấu trúc mở nhìn từ bên trong.
.room-scene { perspective: 260px; }
.room {
transform-style: preserve-3d;
animation: look-around $duration $easing infinite;
}
.room__wall {
position: absolute; inset: 0;
backface-visibility: hidden;
}
.room__wall--back { transform: translateZ(-$half); }
.room__wall--left { transform: rotateY(90deg) translateZ(-$half); }
.room__wall--right { transform: rotateY(-90deg) translateZ(-$half); }
@keyframes look-around {
from { transform: rotateY(-35deg); }
to { transform: rotateY(325deg); }
}
CSS 3D cube dễ hỏng ở đâu? — ba cái bẫy
perspective và transform-style đạt mức MDN Baseline "Widely available" từ tháng 9/2015, theo trang perspective và trang transform-style của MDN. backface-visibility chỉ mới đạt mốc đó từ tháng 3/2022 — muộn nhất trong ba thuộc tính — theo trang backface-visibility, còn caniuse gộp cả ba dưới CSS 3D Transforms và ghi nhận mọi trình duyệt chính hiện tại đều hỗ trợ, nên chín hiệu ứng này an toàn hơn nhiều so với các tính năng mới như view-transition.
Mỗi mục có mặt trước/mặt sau đều gắn transform-style: preserve-3d ngay trên chính phần tử đang xoay, và có lý do cho việc đó. backface-visibility: hidden tự nó không có sức mạnh gì — chỉ cần một tổ tiên nào đó thiếu preserve-3d là ngữ cảnh 3D bị cắt đứt tại đó, phần tử con bị ép phẳng trở lại và mặt sau không lật ra mà chỉ biến mất, dù backface-visibility đã gắn đúng chỗ. Mục 02 vấp một bẫy khác: ghép góc mỗi thẻ bằng chuỗi Sass kiểu rotateY(#{i} * #{step}deg) cho ra đúng văn bản rotateY(0 * 72deg), một biểu thức mà trình duyệt không đọc được nếu thiếu calc(), nên năm thẻ chồng hết lên một chỗ — phải nhân trước ở phía Sass rồi mới nội suy ra một góc đã tính sẵn. Bản sửa lỗi này nằm trong file nén; gõ bfmwjndp làm mật khẩu để mở nó.
Bẫy thứ ba nằm ở mục 06: z-index đặt trong keyframes không phải thuộc tính animate được, nó không nội suy qua các bước mà đổi tức thì ngay tại từng mốc keyframe. Cảm giác các thẻ chồng mượt lên nhau thực ra đến từ việc opacity và scale đổi cùng lúc, đánh lừa mắt — bản thân z-index vẫn nhảy khựng.
Tiếp cận: giảm chuyển động và bàn phím
Dưới prefers-reduced-motion: reduce, chín hiệu ứng không tắt animation theo cùng một cách. Tám trong chín mục — trừ 03 — chỉ đổi animation: none và khoá lại ở một góc cố định riêng cho từng mục, nên nội dung không biến mất đột ngột, chỉ mất quãng xoay dẫn tới đó. Riêng 03 Nghiêng khi hover trả hẳn transition và transform về none, vì đây là hiệu ứng chạy bằng :hover chứ không phải vòng lặp — nếu khoá lại ở góc nghiêng cuối như tám mục kia, người dùng dễ hiểu lầm là card đã nghiêng sẵn dù chưa hover, tưởng thao tác không có phản hồi.
@media (prefers-reduced-motion: reduce) {
.cube { animation: none; transform: rotateY(-28deg); }
.ring { animation: none; transform: rotateY(0deg); }
.tilt, .tilt.is-demo { animation: none; transition: none; transform: none; }
.book__cover { animation: none; transform: rotateY(-150deg); }
.gallery.is-demo .tile__inner { animation: none; transform: rotateY(180deg); }
.flow__card { animation: none; transform: translateX(0) translateZ(0) rotateY(0deg) scale(1); opacity: 1; }
.turntable__object { animation: none; transform: rotateY(-20deg); }
.sign { animation: none; transform: rotateX(-20deg); }
.room { animation: none; transform: rotateY(-15deg); }
}
03 cũng là mục duy nhất dựng sẵn cho bàn phím ngay từ đầu: phần tử mang tabindex="0" nên khi Tab tới, :focus-visible bật đúng góc nghiêng mà chuột tạo ra bằng hover, không phụ thuộc con trỏ có mặt hay không.
.tilt:focus-visible {
transform: rotateX(10deg) rotateY(-14deg);
}
Các demo CSS khác của blog nằm trong category CSS; trang giới thiệu giải thích vì sao mỗi bài ở đây đi kèm số đo thật trước khi lên bài.
FAQ
Perspective nên đặt giá trị bao nhiêu cho hợp lý?
Giá trị càng nhỏ, camera càng "gần", méo càng rõ; giá trị càng lớn thì cảnh càng phẳng. 09 Cuộn phòng 3D cần cảm giác đứng trong phòng nên dùng perspective ngắn 260px, còn 01 Xoay cube 3D chỉ xoay một khối nên 640px là đủ. Không có một con số đúng cho mọi trường hợp — hãy đổi thử vài giá trị trên chính phần tử của bạn rồi so sánh bằng mắt.
Vì sao chín demo xoay nhanh chậm khác nhau?
Mỗi mục dùng riêng biến $duration của nó: 01 Xoay cube 3D chạy 2 giây, 07 Sân khấu xoay chạy 5 giây, còn 09 Cuộn phòng 3D chạy 9 giây. Phần tử càng lớn hoặc càng chiếm nhiều màn hình thì càng cần xoay chậm hơn để mắt theo kịp, tránh gây chóng mặt.
Bản React trong zip render 5 thẻ ở mục 02 và 06 kiểu gì?
Component map một mảng 5 phần tử để render từng thẻ, còn góc rotateY riêng của 02 hay độ trễ animation-delay riêng của 06 được truyền qua thuộc tính style inline theo index thay vì viết cứng trong SCSS. Ba biến chung — duration, easing, color — vẫn nhận qua props như các mục khác.