9 CSS Toggle Switches — dán 1 dòng là chạy
CSS toggle switch là control chỉ dùng một input checkbox để bật tắt thật, còn track và núm là lớp vỏ CSS phủ lên trên.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Trượt cơ bản
- 02 Kiểu iOS
- 03 Nhãn ON/OFF gắn sẵn
- 04 Đổi icon
- 05 Hiệu ứng bóp khi nhấn
- 06 Chuyển màu nền lan tỏa
- 07 Toggle icon mặt trời/mặt trăng
- 08 Mixin biến thể kích cỡ
- 09 Kiểu trạng thái disabled
Chín kiểu này không xếp theo độ hoành tráng mà theo việc track thật sự đổi cái gì. Mở đầu là hai kiểu chỉ dời vị trí núm (01, 02), tiếp đến hai kiểu đổi nội dung bên trong track (03, 04), rồi ba kiểu thêm một lớp diễn xuất như bị bóp hay bị lan màu (05 đến 07), và khép lại bằng hai kiểu — một dựng từ nhiều biến thể, một hoàn toàn không đụng được vào (08, 09). Khối code chỉ giữ đúng bộ chọn cốt lõi của mỗi switch, bản đầy đủ dán vào là chạy nằm trong file zip.
01Trượt cơ bản
Khi checkbox nhận checked, núm tròn trượt sang bên phải track bằng translateX, còn track lật giữa hai lớp nền chồng nhau chỉ qua opacity để đổi màu chứ không animate background-color trực tiếp. Đây là dạng cơ bản nhất để thay checkbox trong màn hình cài đặt.
.switch__bg--off { background: rgba(255, 255, 255, .18); opacity: 1; }
.switch__bg--on { background: $color; opacity: 0; }
.switch__knob { transform: translateX(0); transition: transform $duration $easing; }
.switch__input:checked ~ .switch__track .switch__bg--off { opacity: 0; }
.switch__input:checked ~ .switch__track .switch__bg--on { opacity: 1; }
.switch__input:checked ~ .switch__track .switch__knob { transform: translateX(24px); }
02Kiểu iOS
Track bo tròn xanh phối núm trắng đúng kiểu app Cài đặt của iOS, còn núm nảy vượt qua mốc translateX(24px) một nhịp rồi mới lùi lại đúng chỗ nhờ các mốc keyframes lệch nhau. Hợp với màn hình cài đặt web di động, nơi cảm giác phản hồi dưới ngón tay quan trọng.
@keyframes knob-loop {
0%, 15% { transform: translateX(0); }
38% { transform: translateX(28px); } // nảy quá đà kiểu lò xo
40%, 60% { transform: translateX(24px); }
85%, 100% { transform: translateX(0); }
}
03Nhãn ON/OFF gắn sẵn
Track cố định sẵn chữ ON và OFF ở hai đầu, còn núm mang z-index cao hơn nên khi trượt qua sẽ che mất chữ ở phía nó vừa tới. Hợp với switch trạng thái trên dashboard quản trị, nơi giá trị hiện tại cần đọc được bằng chữ chứ không chỉ bằng màu.
.switch__text--on { left: 10px; }
.switch__text--off { right: 10px; }
.switch__knob {
position: absolute; top: 3px; left: 3px; z-index: 1;
transition: transform $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__knob {
transform: translateX(44px);
}
04Đổi icon
Núm trượt sang bên phải đúng lúc icon bên trong đổi từ dấu X sang dấu tick, cả hai icon nằm chồng lên nhau ở cùng một vị trí và chỉ hoán đổi opacity cho nhau. Hợp với switch cho phép hoặc từ chối quyền, nơi kết quả cần được nói rõ bằng một icon duy nhất.
.switch__knob { transform: translateX(0); transition: transform $duration $easing; }
.switch__icon--x { opacity: 1; }
.switch__icon--check { opacity: 0; }
.switch__input:checked ~ .switch__track .switch__knob { transform: translateX(26px); }
.switch__input:checked ~ .switch__track .switch__icon--x { opacity: 0; }
.switch__input:checked ~ .switch__track .switch__icon--check { opacity: 1; }
05Hiệu ứng bóp khi nhấn
Trong lúc con trỏ còn nhấn xuống, biến --sx kéo núm giãn ngang qua scaleX, còn --sy ép nó dẹt lại theo chiều cao; buông tay thì cả hai biến trở về 1 nên núm bật lại thành hình tròn. Hợp với form di động ưu tiên chạm, nơi cần một tín hiệu tức thì báo rằng ngón tay đã chạm đúng chỗ.
.switch__knob {
--x: 0px; --sx: 1; --sy: 1;
transform: translateX(var(--x)) scaleX(var(--sx)) scaleY(var(--sy));
transition: transform $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__knob { --x: 24px; }
.switch:active .switch__knob { --sx: 1.35; --sy: .8; }
06Chuyển màu nền lan tỏa
Nền track không đổi màu ngay lập tức mà một vòng tròn clip-path mở rộng dần từ đúng vị trí núm đứng, lan ra tới khi phủ gần hết track. Hợp với card cài đặt mang cảm giác cao cấp hoặc switch demo trên trang landing, nơi bản thân khoảnh khắc chuyển đổi cũng đáng để xem.
.switch__fill {
background: $color;
clip-path: circle(0% at 18% 50%);
transition: clip-path $duration $easing;
}
.switch__input:checked ~ .switch__track .switch__fill {
clip-path: circle(75% at 82% 50%);
}
07Toggle icon mặt trời/mặt trăng
Chính track nơi núm di chuyển trở thành bầu trời: nền gradient dịch background-position từ nửa vàng sang nửa màu tối, còn vài chấm sao hiện dần lên qua opacity. Hợp với switch đổi theme hoặc cài đặt kiểu app thời tiết, nơi bản thân track đã kể hết câu chuyện ngày và đêm.
.switch__track {
background-image: linear-gradient(90deg, $stage-yellow 0%, $stage-yellow 50%, $color 50%, $color 100%);
background-size: 200% 100%;
background-position: 0% 0%;
transition: background-position $duration $easing;
}
.switch__input:checked ~ .switch__track { background-position: 100% 0%; }
.switch__input:checked ~ .switch__track .switch__star { opacity: 1; }
08Mixin biến thể kích cỡ
Một mixin SCSS nhận vào chiều rộng, chiều cao và kích cỡ núm rồi tự tính quãng đường --travel cần trượt, nên ba switch nhỏ/vừa/lớn ra đời từ đúng ba lời gọi @include. Cả ba dùng chung một animation translateX nên đổi tốc độ ở một chỗ là cả ba cùng đổi theo. Hợp với component trong design system, nơi không ai muốn đo lại quãng trượt mỗi lần thêm một kích cỡ mới.
@mixin switch-size($track-w, $track-h, $knob) {
--travel: #{$track-w - $knob - 6px};
.switch__track { width: $track-w; height: $track-h; }
.switch__knob { width: $knob; height: $knob; }
}
.switch--sm { @include switch-size(36px, 20px, 14px); }
.switch--md { @include switch-size(48px, 26px, 20px); }
.switch--lg { @include switch-size(60px, 32px, 26px); }
09Kiểu trạng thái disabled
Switch mang thuộc tính disabled mờ dần qua filter: grayscale và opacity rồi đứng yên hẳn ở đó, con trỏ đổi thành not-allowed để xác nhận không còn bấm được nữa. Hợp với mục cài đặt mà người dùng không có quyền, nơi trạng thái mờ đi tự nó đã là câu trả lời.
.switch__input:disabled ~ .switch__track { filter: grayscale(1); opacity: .45; }
.switch__input:disabled ~ .switch__label { opacity: .55; }
@keyframes fade-loop {
0%, 20% { filter: grayscale(0); opacity: 1; }
50%, 70% { filter: grayscale(1); opacity: .45; }
100% { filter: grayscale(0); opacity: 1; }
}
Chỗ dễ hỏng ở đâu? — cái bẫy
Hơn nửa trong chín switch này không animate thẳng background-color mà xếp hai lớp chồng lên nhau rồi chỉ đổi opacity giữa chúng, như mục 01 và 04 đang làm. Nếu animate màu nền trực tiếp, giá trị giữa chừng phải nội suy qua không gian màu và mỗi trình duyệt trả về một sắc xám đục khác nhau, còn đổi độ trong suốt của hai lớp sẵn màu thì mỗi lớp vẫn giữ đúng màu gốc suốt quá trình. Tài liệu hiệu năng animation của MDN khuyên ưu tiên đúng hai thuộc tính transform và opacity vì lý do này. clip-path ở mục 06 và background-position ở mục 07 là hai ngoại lệ được giữ lại, vì cả hai chỉ buộc trình duyệt vẽ lại chứ không tính lại layout — caniuse.com xác nhận clip-path đã được các trình duyệt chính hỗ trợ từ rất lâu.
Một điểm không hẳn là lỗi nhưng đáng nhớ: chữ ON và OFF ở mục 03 cần track rộng hơn hẳn đường kính núm, nếu không phần chữ sẽ bị che nửa vời ngay giữa cú trượt và trông rất vụng. Bản nén giữ nguyên các con số chiều rộng track và quãng trượt ghi ở trên chỉ mở ra khi gõ đúng mật khẩu a2ejkfw2, nút tải nằm ngay bên dưới.
Khả năng tiếp cận
Cả chín switch đều là <input type="checkbox"> gắn role="switch", nên Tab đưa focus tới và phím Space đổi giá trị như một checkbox thật (riêng 09 bị disabled nên bị loại khỏi thứ tự focus ngay từ đầu). :focus-visible được vẽ thêm một viền ngoài như bên dưới, và từ 01 đến 08, mỗi lần sự kiện change nổ ra thì aria-checked được JS set lại để trình đọc màn hình cũ không bỏ lỡ trạng thái, còn 09 vốn không bao giờ bật lại nên aria-checked="false" được gắn cố định ngay trong markup.
<label class="switch">
<input type="checkbox" class="switch__input" role="switch" aria-checked="false">
<span class="switch__track"><span class="switch__knob"></span></span>
</label>
.switch__input:focus-visible ~ .switch__track {
outline: 2px solid currentColor;
outline-offset: 3px;
}
Khi prefers-reduced-motion: reduce được bật, cả chín switch chỉ tắt phần chuyển động còn trạng thái bật/tắt vẫn hiện đúng ngay lập tức. Riêng mục 09 vốn đã đứng yên vì bị khoá disabled, nên thiết lập này không đổi gì thêm ở đó. Tài liệu prefers-reduced-motion của MDN ghi chi tiết mức hỗ trợ trình duyệt cho media query này.
Muốn xem thêm control dạng checkbox khác thì ghé danh sách hiệu ứng CSS, còn muốn biết godrichstory thật ra bán gì thì đọc trang giới thiệu.
FAQ
Toggle switch khác checkbox thường ở chỗ nào?
Markup vẫn là cùng một <input type="checkbox">, chỉ thêm role="switch" để trình đọc màn hình gọi nó là switch thay vì checkbox. Giá trị đổi ngay khi bấm (như bật thông báo) hợp với switch hơn, còn giá trị chỉ có hiệu lực sau khi bấm nút lưu thì vẫn nên giữ checkbox.
Mục 09 đã disabled thì sao vẫn thấy chuyển động?
Bản demo trong lưới chỉ lặp lại hiệu ứng mờ dần để minh hoạ đúng lúc thuộc tính disabled bật lên, còn control thật luôn đứng yên ở trạng thái khoá. Không ai bấm tay vào bật tắt được nó cả.
Dùng trong React thì thay class bằng gì?
Component trong thư mục react/ của zip giữ checked bằng useState, còn ba biến số được nhận qua props rồi đẩy vào làm biến CSS. Module SCSS chỉ đọc var() nên đổi vài giá trị là ra một switch màu khác hoặc tốc độ khác ngay.