GODRICH

9 CSS Checkbox Radio — dán 1 dòng là chạy

CSS checkbox radio tùy biến là cách vẽ lại hình dạng cạnh input gốc bằng một pseudo-class :checked, input thật vẫn còn nguyên.

Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip

Chín kiểu không xếp theo độ nổi tiếng mà xếp theo số lựa chọn tăng dần. Mục 01, 02, 06 và 09 là một checkbox tự bật tắt một mình; mục 03, 05, 07 và 08 là nhóm radio buộc chỉ còn đúng một lựa chọn; còn mục 04 là một checkbox mượn hẳn dáng switch.

01Vẽ nét tick bằng stroke

Nhấn vào checkbox thì :checked kéo stroke-dashoffset của path SVG từ 20 về 0, nên nét tick hiện dần đúng theo hướng đã vẽ path. Hợp với checkbox đồng ý điều khoản hay đánh dấu một việc đã xong trong to-do.

stroke-dashoffset:checked0.6초
.box {
  border: 2px solid rgba($subject-cream, .55);
  transition: background $duration $easing, border-color $duration $easing;
}
.sr-input:checked ~ .box { background: $color; border-color: $color; }

.box__path {
  stroke-dasharray: 20;
  stroke-dashoffset: 20;
  transition: stroke-dashoffset $duration $easing;
}
.sr-input:checked ~ .box .box__path { stroke-dashoffset: 0; }

02Tick nảy bật

Vừa được check, icon bên trong bật bằng @keyframes check-pop, phóng từ scale(0) lên 1.3 lần rồi lắng xuống đúng 1 lần. Hợp với chọn nhiều tùy chọn cài đặt hoặc checkbox thêm vào giỏ hàng, nơi phản hồi cần rộn ràng một chút.

scale 바운스cubic-bezier0.6초
.sr-input:checked ~ .box .box__svg {
  animation: check-pop $duration $easing forwards;
}

@keyframes check-pop {
  0%   { transform: scale(0); opacity: 0; }
  55%  { transform: scale(1.3); opacity: 1; }
  75%  { transform: scale(.9); }
  100% { transform: scale(1); opacity: 1; }
}

03Radio tô đầy dần

Chọn một trong ba radio thì chấm tròn radio__dot::after phóng từ scale(0) lên scale(1), còn chấm ở lựa chọn cũ biến mất ngay vì dùng chung một rule :checked. Hợp với chọn phương thức thanh toán hay chọn tùy chọn giao hàng, nơi chỉ đúng một lựa chọn được tồn tại.

scale:checked0.22초
.radio__dot::after {
  content: "";
  width: 12px; height: 12px;
  border-radius: 50%;
  background: $color;
  transform: scale(0);
  transition: transform $duration $easing;
}
.sr-input:checked ~ .radio__dot::after { transform: scale(1); }

04Checkbox kiểu switch

Nhìn ngoài giống switch nhưng núm switch__knob đứng yên tại chỗ, còn nền switch__fill tô bằng vòng tròn clip-path lớn dần từ 0% lên 75% để báo trạng thái bật. Hai icon on/off trong núm đổi opacity và scale để thay nhau hiện ra. Hợp với switch tắt tiếng thông báo hoặc toggle tự động lưu.

clip-path:circle():checked0.3초
.switch__fill {
  position: absolute; inset: 0; background: $color;
  clip-path: circle(0% at 50% 50%); transition: clip-path $duration $easing;
}
.sr-input:checked ~ .switch__track .switch__fill { clip-path: circle(75% at 50% 50%); }
.switch__icon--on { opacity: 0; transform: scale(.6); }
.switch__icon--off { opacity: 1; transform: scale(1); }
.sr-input:checked ~ .switch__track .switch__icon--on { opacity: 1; transform: scale(1); }
.sr-input:checked ~ .switch__track .switch__icon--off { opacity: 0; transform: scale(.6); }

05Kiểu chọn thẻ card

Chọn một card trong nhóm radio thì :has(.sr-input:checked) làm nổi viền và nền của đúng card đó, không cần thêm class nào bằng JavaScript. Badge tròn ở góc trên phải cũng đổi màu và icon tick bên trong bật opacity, scale theo cùng rule :has(). Hợp với card chọn gói giá hoặc card chọn phương thức giao hàng.

:has()scale0.3초
.card:has(.sr-input:checked) {
  border-color: $color;
  background: rgba($subject-cream, .14);
}
.card:has(.sr-input:checked) .card__badge {
  border-color: $color;
  background: $color;
}
.card:has(.sr-input:checked) .card__badge svg {
  opacity: 1; transform: scale(1); stroke: $stage-ink;
}

06Gạch ngang việc đã xong

Check một việc trong to-do thì gạch todo__strike kéo từ trái sang phải bằng scaleX(0) lên scaleX(1), đồng thời chữ todo__label mờ dần xuống opacity .45. Hợp với đánh dấu hoàn thành trong danh sách to-do hay mục checklist.

scaleXopacity0.3초
.todo__strike {
  position: absolute; inset-inline: 0; top: 50%;
  height: 2px; background: currentColor;
  transform: scaleX(0); transform-origin: left;
  transition: transform $duration $easing;
}
.sr-input:checked ~ .todo__text .todo__strike { transform: scaleX(1); }
.sr-input:checked ~ .todo__text .todo__label { opacity: .45; }

07Chọn mẫu màu swatch

Chọn một mẫu màu thì vòng ::after quanh nó phóng từ scale(0) lên scale(1) nhờ :has(.sr-input:checked), không phải sibling selector như các mục radio khác. Bốn màu swatch dùng lại đúng bốn token thiết kế sẵn có, không thêm hex mới. Hợp với chọn màu tùy chọn sản phẩm hay chọn màu theme.

box-shadow 링scale0.22초
.swatch::after {
  content: "";
  position: absolute; inset: 0;
  border-radius: 50%;
  border: 2px solid $color;
  transform: scale(0);
  opacity: 0;
  transition: transform $duration $easing, opacity $duration $easing;
}
.swatch:has(.sr-input:checked)::after { transform: scale(1); opacity: 1; }

08Nhập đánh giá sao

Năm radio xếp theo thứ tự 5→1 rồi lật bằng row-reverse để màn hình hiện đúng 1→5, nên chọn một sao thì sibling selector ~ tô luôn mọi sao đứng trước nó. Hợp với đánh giá sao cho review hay khảo sát mức độ hài lòng.

~ 형제 선택자clip-path0.22초
.stars { display: flex; flex-direction: row-reverse; gap: 6px; }
.star__icon {
  display: block; width: 26px; height: 26px;
  background: rgba($subject-ink, .22);
  transition: background $duration $easing;
}
.sr-input:checked ~ .star .star__icon,
.sr-input:checked ~ .star ~ .star .star__icon { background: $color; }

09Toggle dạng chip

Nhấn checkbox dạng chip thì chip__fill tô nền từ giữa ra ngoài bằng scaleX(0) lên scaleX(1), còn :active làm cả chip lún nhẹ về scale(.96). Hợp với chọn nhiều tag hay chọn chip lọc, nơi có thể bật nhiều chip cùng lúc.

scaleXpress scale0.22초
.chip__fill {
  position: absolute; inset: 0;
  background: $color; z-index: 0;
  transform: scaleX(0);
  transform-origin: center;
  transition: transform $duration $easing;
}
.sr-input:checked ~ .chip__fill { transform: scaleX(1); }
.chip:active { transform: scale(.96); }

Chỗ dễ hỏng ở đâu? — hai kiểu bẫy khi tự vẽ input

Cả chín demo giấu input thật bằng class sr-input — position:absolute, width và height 1px, clip:rect(0,0,0,0) — chứ không dùng display:none hay visibility:hidden. Hai thuộc tính đó kéo phần tử ra khỏi thứ tự focus hoàn toàn, nên người chỉ dùng bàn phím sẽ không bao giờ Tab tới được checkbox hay radio thật, dù hình vẽ bên cạnh trông vẫn bấm được bằng chuột. Mục 05 và 07 còn dựa vào :has(.sr-input:checked) để tô lại cả .card hoặc ::after của .swatch từ một input không phải anh em trực tiếp, một selector mới chỉ chạy ổn định từ Chrome/Edge 105+, Safari 15.4+ và Firefox 121+ theo bảng hỗ trợ trên caniuse — dưới các mốc đó viền card hay vòng swatch không nổi lên, nhưng bấm chọn vẫn hoạt động bình thường vì :checked tự nó không cần :has(). Chín file index.html trong zip có mật khẩu 8zg24hjv không có đoạn script 4-6 dòng tự đảo :checked mỗi nửa giây mà bản demo bên trên dùng để tự lặp, nên trong file thật chỉ có đúng cú click hay chạm thật của người dùng mới đổi được :checked.

Khả năng tiếp cận

Vì input thật chỉ bị giấu bằng kỹ thuật clip chứ không bị gỡ khỏi DOM, trình đọc màn hình vẫn đọc đúng checkbox hay radio, còn Tab, phím cách và phím mũi tên vẫn đổi được :checked như một input bình thường. Cả chín demo đều có thêm vòng focus-visible quanh hình vẽ bên cạnh. Riêng mục 02, vì hiệu ứng chạy bằng @keyframes chứ không phải transition, nên phần reduced-motion phải tắt bằng animation:none cùng transform: scale(1) cố định để nhảy thẳng tới trạng thái cuối — keyframes bỏ qua transition-duration. Trang prefers-reduced-motion của MDN giải thích mức hỗ trợ trình duyệt cho media query này.

.sr-input:focus-visible ~ .box {
  box-shadow: 0 0 0 3px rgba($subject-cream, .5);
}

@media (prefers-reduced-motion: reduce) {
  .box, .box__path { transition-duration: 1ms !important; }
}

Xem thêm các bài CSS khác trong chuyên mục CSS, còn trang giới thiệu nói rõ vì sao site này đóng gói mỗi bài thành một file zip có mật khẩu.

FAQ

Checkbox hay radio, chọn cái nào cho đúng?

Một trạng thái bật tắt độc lập thì dùng checkbox, còn khi chỉ được giữ lại đúng một lựa chọn giữa nhiều tùy chọn thì dùng radio. Mục 04 trông như switch nhưng bản chất vẫn là checkbox nên nhiều switch có thể bật cùng lúc, khác hẳn nhóm radio ở mục 03, 05, 07 và 08 chỉ giữ lại đúng một lựa chọn.

Không có label, chỉ có aria-label thì có ổn không?

Với swatch màu hay sao đánh giá nơi hình vẽ đã nói lên đủ ý nghĩa, aria-label như trong mục 07 và 08 là đủ. Còn với mục có cả câu chữ đi kèm như 01, nên bọc câu chữ đó trong thẻ label để toàn bộ vùng chữ cũng nằm trong vùng bấm được, chạm trên điện thoại sẽ dễ trúng hơn.

Bản react/ trong zip xử lý :checked thế nào?

Class CSS Module vẫn giữ nguyên selector :checked vì đó là pseudo-class của trình duyệt, không phải thứ React biên dịch lại. Nhưng để thành controlled component, mỗi component trong thư mục react/ của zip quản lý checked bằng useState và đổi giá trị qua onChange, áp dụng như vậy cho cả chín mục trong thư mục này.

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.