9 CSS Tab Switch Animations — gần như chỉ CSS
CSS tab switch animation là hiệu ứng chuyển tab chạy gần như chỉ bằng CSS, dùng trạng thái :checked của radio hoặc role="tablist" thật để dời
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Gạch chân trượt theo tab
- 02 Nền pill di chuyển
- 03 Panel chuyển bằng fade
- 04 Panel trượt ngang
- 05 Tab icon
- 06 Tab cuộn ngang, snap từng ô
- 07 Tab dọc
- 08 Tab bounce
- 09 Tab có badge số
Chín kiểu dưới đây xếp theo mức độ can thiệp: 01 đến 04 và 06 đến 08 chỉ cần :checked của radio ẩn để tự chuyển tab — 3 dòng JS ở mỗi mục chỉ chạy lúc bạn bấm thật để tắt vòng lặp demo, không tham gia vào việc đổi tab. 05 và 09 khác hẳn phần còn lại — cả hai dùng role="tablist" cùng một hàm select() 9 dòng, bấm chuột hay phím mũi tên trái/phải đều gọi đúng hàm đó để dời aria-selected và focus. Ở lưới phía trên bạn thấy cả chín ô tự đổi tab theo chu kỳ, vì khung xem trước không nhận sự kiện chuột thật nên mỗi ô tự lặp lại trạng thái của chính nó.
01Gạch chân trượt theo tab
Ba tab được dựng bằng <input type="radio">, gạch chân bên dưới nhận :checked qua sibling combinator (~) rồi translateX tới đúng vị trí tab vừa chọn. Vì đây là radio group gốc của trình duyệt, phím mũi tên đã tự chuyển tab mà không cần thêm JS.
.us__indicator {
position: absolute; left: 0; bottom: 0;
width: calc(100% / 3); height: 3px;
background: $color;
transform: translateX(0);
transition: transform $duration $easing;
}
#us-1:checked ~ .us__nav .us__indicator { transform: translateX(0%); }
#us-2:checked ~ .us__nav .us__indicator { transform: translateX(100%); }
#us-3:checked ~ .us__nav .us__indicator { transform: translateX(200%); }
02Nền pill di chuyển
Nền hình viên thuốc nằm phía sau các tab trượt và co giãn bằng translateX cùng scaleX tới đúng ô của tab được chọn. Vì mỗi label rộng khác nhau, tỉ lệ scaleX phải tính sẵn thành số ở bước biên dịch SCSS thay vì chia trong calc() lúc chạy.
.pi__pill {
position: absolute; top: $sp-1; left: $sp-1; bottom: $sp-1;
width: $w1;
background: $color;
transform: translateX(0) scaleX(1);
transform-origin: left center;
transition: transform $duration $easing;
}
#pi-2:checked ~ .pi__nav .pi__pill {
transform: translateX(calc(#{$w1} + #{$sp-2})) scaleX(1.357);
}
03Panel chuyển bằng fade
Ba panel xếp chồng đúng một ô grid, chỉ opacity của panel đang :checked chuyển từ 0 lên 1 nên vị trí panel không bao giờ xê dịch khi đổi tab.
.fp__panel {
grid-area: 1 / 1;
opacity: 0;
pointer-events: none;
transition: opacity $duration $easing;
}
#fp-1:checked ~ .fp__panels .fp__panel--1,
#fp-2:checked ~ .fp__panels .fp__panel--2,
#fp-3:checked ~ .fp__panels .fp__panel--3 { opacity: 1; pointer-events: auto; }
04Panel trượt ngang
Ba panel nằm cạnh nhau trên track rộng 300% bên trong khung overflow:hidden, cả track dịch translateX(-33.333%×n) để panel của tab vừa chọn lọt vào khung nhìn. Gạch chân dưới tab dùng chung một bộ @keyframes với track để cả hai luôn khớp nhịp.
.sp__track {
display: flex;
width: 300%;
transform: translateX(0%);
transition: transform $duration $easing;
}
#sp-2:checked ~ .sp__viewport .sp__track { transform: translateX(-33.333%); }
#sp-3:checked ~ .sp__viewport .sp__track { transform: translateX(-66.667%); }
05Tab icon
Ba nút mang role="tablist" thật, aria-selected đổi ngay khi bạn bấm hoặc dùng phím mũi tên trái/phải, và cùng một hàm select() 9 dòng xử lý cả click lẫn bàn phím. Icon vừa chọn phóng nhẹ bằng transform:scale(1.18).
function select(i) {
tabs.forEach(function (t, j) {
var on = j === i;
t.setAttribute('aria-selected', on);
t.tabIndex = on ? 0 : -1;
});
tabs[i].focus();
}
tabs[0].addEventListener('keydown', function (e) {
if (e.key === 'ArrowRight') select(1);
});
06Tab cuộn ngang, snap từng ô
Năm tab rộng cố định 88px tràn khỏi khung chứa, scroll-snap-type: x mandatory cùng scroll-snap-align: start trên mỗi tab khiến việc cuộn dừng gọn từng tab một. Gạch chân indicator vẫn trượt translateX(88px×n) theo :checked như mục 01.
.st__scroller {
overflow-x: auto;
scroll-snap-type: x mandatory;
}
.st__tab {
flex: 0 0 88px;
scroll-snap-align: start;
}
#st-2:checked ~ .st__scroller .st__indicator { transform: translateX(88px); }
07Tab dọc
Trong menu tab xếp dọc, một thanh 2px bên trái trượt translateY(0/hàng/hàng×2) theo :checked để chỉ ra mục đang mở. Vì danh sách dọc dễ có thêm hàng về sau, chiều cao mỗi bước phải tính theo chiều cao một hàng chứ không phải một số cố định.
.vt__indicator {
position: absolute; left: -2px; top: 0;
width: 2px; height: $row-h;
background: $color;
transform: translateY(0);
transition: transform $duration $easing;
}
#vt-2:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h}); }
#vt-3:checked ~ .vt__nav .vt__indicator { transform: translateY(#{$row-h * 2}); }
08Tab bounce
Indicator dạng pill trượt translateX bằng easing overshoot rồi bật nhẹ lại đúng vị trí, còn chữ của tab vừa chọn nảy một lần bằng @keyframes riêng. Màu chữ được giữ cố định một màu — không đổi màu theo trạng thái — để tránh chữ gần như biến mất trên nền sáng lúc indicator chưa kịp tới nơi.
.bt__indicator {
position: absolute; top: $sp-1; bottom: $sp-1; left: $sp-1;
width: calc((100% - #{$sp-1} * 2) / 3);
background: $color;
transform: translateX(0);
transition: transform $duration $easing;
}
#bt-2:checked ~ .bt__nav .bt__indicator { transform: translateX(100%); }
#bt-2:checked ~ .bt__nav label[for="bt-2"] .bt__text { animation: bt-pop 400ms $easing; }
@keyframes bt-pop { 55% { transform: scale(1.18); } }
09Tab có badge số
Cùng cấu trúc role="tablist" và hàm select() như mục 05, mỗi tab ở đây có thêm một badge số. Chỉ badge của tab vừa mở nảy lên bằng scale(1.35) qua @keyframes, các badge còn lại đứng yên.
.cb__badge {
transform: scale(1);
transition: transform $duration $easing;
}
.cb__tab[aria-selected="true"] .cb__badge {
animation: cb-pop $duration $easing;
}
@keyframes cb-pop {
0% { transform: scale(1); }
55% { transform: scale(1.35); }
100% { transform: scale(1); }
}
Chỗ dễ hỏng ở đâu — cái bẫy
Lúc dựng mục 02, từng thử viết thẳng tỉ lệ scaleX(calc(76px / 56px)) ngay trong CSS cho đỡ phải tính tay, thì cả khai báo bị trình duyệt bỏ qua và pill đứng im không hề di chuyển. Trang tham khảo calc() của MDN ghi rõ phép chia chỉ nhận một số thuần ở một trong hai vế, không cho chia một length cho một length khác — nên tỉ lệ đó phải tính sẵn thành con số ở bước biên dịch SCSS rồi mới đưa vào transform.
Bẫy lớn hơn nằm ở mục 04: ban đầu track chứa panel và gạch chân dưới tab được viết bằng hai bộ @keyframes khác nhau cho vòng lặp demo, nên có lúc track đã hiện panel bước hai mà gạch chân vẫn đứng ở bước một — hai phần tử lệch nhịp thật sự lộ ra khi quay lại video demo. Mục 08 gặp một dạng lệch khác: chữ tab đang chọn từng được đổi sang màu kem, và trong lúc indicator ở vòng lặp demo đang đứng tại một tab khác thì chữ kem đó gần như tan biến trên nền trắng. Bản chín file trong file nén đã gộp track với gạch chân vào chung một bộ @keyframes và giữ màu chữ cố định; nhập đúng dãy ký tự 32dvwmre, không chèn dấu cách nào, là mở được.
Khả năng tiếp cận
Từ 01 đến 04, 06 và 07 chỉ tắt transition khi prefers-reduced-motion: reduce bật, nên indicator và panel hiện thẳng ở trạng thái của tab đang :checked, vòng lặp demo cũng dừng theo. Mục 08 tắt cả transition lẫn animation cùng lúc, vì vừa có indicator trượt bằng transition vừa có chữ nảy bằng @keyframes. Mục 05 và 09 khác hẳn phần còn lại — animation-delay chỉ phục vụ vòng lặp demo tự chạy chứ không phải lần bấm thật, nên reduced motion chỉ tắt animation đó, còn click và phím mũi tên thật vẫn đổi aria-selected và scale như bình thường. Trang prefers-reduced-motion của MDN và mẫu tab của W3C ARIA Authoring Practices là hai nguồn cho phần này — mẫu ARIA cũng là chuẩn mà 05 và 09 theo cho phím mũi tên.
Các hiệu ứng CSS khác nằm trong chuyên mục CSS, còn trang giới thiệu nói rõ site này thật ra bán gì.
FAQ
Tab nào bắt buộc phải dùng role="tablist"?
Chỉ 05 và 09. Bảy kiểu còn lại dựng bằng <input type="radio"> nên trình duyệt đã tự cấp accessibility tree và phím mũi tên miễn phí, role="tablist" chỉ cần khi markup là nút bấm chứ không phải radio.
Panel và indicator lệch nhau là do đâu?
Do chúng được viết bằng hai animation riêng cho vòng lặp demo, như mục 04 và 08 từng gặp thật trong lúc quay đo. Gộp cả hai vào chung một bộ @keyframes là cách sửa duy nhất, vì để mỗi phần tử tự chạy animation riêng rất dễ trôi nhịp theo thời gian.
Dùng React thay vì radio thì làm thế nào?
useState giữ index tab đang chọn, rồi truyền index đó qua CSS variable --i để tính transform: translateX(calc(var(--i) * 100%)) cho indicator. Thư mục react/ trong file nén có sẵn bản component cho cả 05 và 09, với hàm select() gốc viết lại thành setActive/move bằng hook useState.