GODRICH

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

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.

:checkedtranslateX300ms
.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.

translateXscaleX:checked
.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.

opacitygrid 겹침220ms
.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.

translateXoverflow:hidden300ms
.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).

role=tablistaria-selectedscale
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.

scroll-snap-typeoverflow-xtranslateX
.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.

translateY:checked300ms
.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.

cubic-bezier 스프링scaletranslateX
.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.

role=tablistaria-selectedscale
.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-selectedscale như bình thường. Trang prefers-reduced-motion của MDNmẫ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.

Nhập mật khẩu file nén

Mật khẩu nằm ngay trong bài viết này. Đọc tới đó bạn sẽ thấy.