9 hiệu ứng css dark mode toggle 1 dòng CSS
Css dark mode toggle là nút chuyển đổi giữa giao diện sáng và tối, thường đặt ở header hoặc menu cài đặt.
Tự chạy · bấm ô để tới mục đó · cả 9 trong một zip
- 01 Đổi icon mặt trời ↔ mặt trăng
- 02 Nút trượt
- 03 Lan tỏa hình tròn (View Transitions)
- 04 Chuyển 1 dòng với light-dark()
- 05 Mask nhật thực
- 06 Lật thẻ
- 07 Morph icon
- 08 Fade toàn bộ màu
- 09 3 trạng thái sáng · tối · tự động
Thứ tự chín hiệu ứng đi từ những công tắc quen mắt nhất đến những kỹ thuật chỉ đổi đúng một giá trị CSS. 01, 02 và 09 là dạng công tắc cổ điển, nơi bạn nhìn thấy chính nút gạt hoặc icon di chuyển. Từ 03 đến 07 tập trung vào icon hoặc cả khung đổi màu, còn 04 và 08 bỏ hẳn phần nút bấm riêng để chỉ đổi màu nền và chữ. Ở lưới phía trên, bạn xem từng ô tự chuyển từ sáng sang tối rồi quay lại sáng; bản dùng đúng sự kiện click nằm trong file zip.
01Đổi icon mặt trời ↔ mặt trăng
Nhấn toggle thì icon mặt trời xoay và biến thành mặt trăng, chỉ bằng transform: rotate() và crossfade opacity giữa hai lớp icon chồng lên nhau. Hợp với nút dark mode trên header hoặc menu cài đặt, nơi chính icon đóng vai trò nút bấm.
.toggle__sun {
background: $stage-yellow; opacity: 1; transform: rotate(0deg);
animation: sun $duration $easing infinite;
}
.toggle__moon {
background: $subject-cream; opacity: 0; transform: rotate(-90deg);
animation: moon $duration $easing infinite;
}
@keyframes sun {
0% { opacity: 1; transform: rotate(0deg); }
50% { opacity: 0; transform: rotate(90deg); }
100% { opacity: 1; transform: rotate(0deg); }
}
@keyframes moon {
0% { opacity: 0; transform: rotate(-90deg); }
50% { opacity: 1; transform: rotate(0deg); }
100% { opacity: 0; transform: rotate(-90deg); }
}
02Nút trượt
Nút trượt trên track di chuyển trái phải còn màu nền track đổi theo, đúng kiểu công tắc mà mắt đã quen từ điện thoại. Dùng cho toggle trong màn hình cài đặt hoặc nút bên phải thanh điều hướng.
.knob {
position: absolute; top: 3px; left: 3px; width: 18px; height: 18px; border-radius: 50%;
background: $color; box-shadow: $shadow-press;
transform: translateX(0); animation: knob-slide $duration $easing infinite;
}
.track__bg--dark {
background: $subject-ink; opacity: 0; animation: face-dark $duration $easing infinite;
}
@keyframes knob-slide {
0% { transform: translateX(0); }
50% { transform: translateX(20px); }
100% { transform: translateX(0); }
}
03Lan tỏa hình tròn (View Transitions)
Vòng tròn lan ra từ điểm nhấn rồi phủ kín cả khung, vòng tròn đó scale từ 0 lên 1 chính là đoạn CSS mà View Transitions API dùng để clip ảnh chụp của trang cũ. Hợp với toggle hero trang landing hoặc màn hình mở đầu app.
.wipe {
position: absolute; top: -60px; right: -60px;
width: 320px; height: 320px; border-radius: 50%;
background: $color; transform-origin: 78% 22%;
transform: scale(0); animation: wipe-scale $duration $easing infinite;
}
@keyframes wipe-scale {
0% { transform: scale(0); }
50% { transform: scale(1); }
100% { transform: scale(0); }
}
04Chuyển 1 dòng với light-dark()
Chỉ cần đổi color-scheme, màu nền và chữ sẽ tự đổi cùng lúc nhờ light-dark(). Ở đây một ô màu nhỏ đổi opacity đại diện cho cú lật một bước đó, hợp với thư viện component hoặc token của design system nơi bạn không muốn animate riêng từng nơi dùng màu.
.chip__swatch--light {
background: $color; opacity: 1;
animation: face-light $duration $easing infinite;
}
.chip__swatch--dark {
background: $stage-yellow; opacity: 0;
animation: face-dark $duration $easing infinite;
}
@keyframes face-light { 0% { opacity: 1; } 45% { opacity: 0; } 100% { opacity: 1; } }
@keyframes face-dark { 0% { opacity: 0; } 45% { opacity: 1; } 100% { opacity: 0; } }
05Mask nhật thực
Mask hình mặt trăng trượt từ ngoài vào che dần icon mặt trời khi chuyển sang dark mode, chỉ dùng transform: translateX() trên một hình tròn tối màu. Hợp với toggle nhấn mạnh thương hiệu hoặc trang giới thiệu sản phẩm.
.mask {
position: absolute; bottom: 6px; left: 0;
width: 26px; height: 26px; border-radius: 50%; background: $stage-ink;
transform: translateX(-60px); animation: mask-slide $duration $easing infinite;
}
@keyframes mask-slide {
0% { transform: translateX(-60px); }
50% { transform: translateX(4px); }
100% { transform: translateX(-60px); }
}
06Lật thẻ
Card toggle lật theo trục Y nhờ perspective và transform-style: preserve-3d, từ mặt trời ở mặt trước sang mặt trăng ở mặt sau. Hợp với công tắc cài đặt dạng card hoặc menu hồ sơ.
.card__inner {
position: relative; width: 100%; height: 100%; transform-style: preserve-3d;
animation: flip $duration $easing infinite;
}
.card__face--front { background: $subject-cream; color: $subject-ink; }
.card__face--back { background: $stage-ink; color: $subject-cream; transform: rotateY(180deg); }
@keyframes flip {
0% { transform: rotateY(0deg); }
50% { transform: rotateY(180deg); }
100% { transform: rotateY(360deg); }
}
07Morph icon
Tia sáng của icon mặt trời gập lại rồi morph mượt thành các hố trên mặt trăng, chỉ bằng scaleY() trên từng tia và crossfade giữa hai lớp lõi. Hợp với nút chỉ có icon hoặc toggle trên toolbar.
.icon__core--sun { background: $color; opacity: 1; animation: core-light $duration $easing infinite; }
.icon__core--moon { background: $stage-ink; opacity: 0; animation: core-dark $duration $easing infinite; }
.icon__ray {
position: absolute; top: 50%; left: 50%; width: 3px; height: 8px; border-radius: 2px;
background: $color; transform-origin: 50% 17px;
transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1);
animation: ray-fold $duration $easing infinite;
}
@keyframes ray-fold {
0% { opacity: 1; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1); }
50% { opacity: 0; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(0); }
100% { opacity: 1; transform: rotate(calc(var(--i) * 60deg)) translateY(-17px) scaleY(1); }
}
08Fade toàn bộ màu
Màu nền và chữ cùng lúc đổi opacity qua lại giữa hai lớp phủ kín, không có nút gạt hay icon nào đóng vai trò nút bấm riêng — chỉ có cả trang đổi màu cùng một lúc. Hợp với chuyển theme toàn trang hoặc trình xem tài liệu.
.panel__face--light {
background: $subject-cream; color: $subject-ink;
opacity: 1; animation: face-light $duration $easing infinite;
}
.panel__face--dark {
background: $stage-ink; color: $subject-cream;
opacity: 0; animation: face-dark $duration $easing infinite;
}
@keyframes face-light { 0% { opacity: 1; } 45% { opacity: 0; } 100% { opacity: 1; } }
@keyframes face-dark { 0% { opacity: 0; } 45% { opacity: 1; } 100% { opacity: 0; } }
093 trạng thái sáng · tối · tự động
Công tắc 3 ô di chuyển chỉ báo bằng transform: translateX() theo thứ tự sáng, tối rồi tự động, mỗi bước dừng đúng một ô. Hợp với trang cài đặt chi tiết hoặc tùy chọn app desktop.
.segmented__thumb {
position: absolute; top: 3px; left: 3px; width: 26px; height: 22px; border-radius: $r-pill;
background: $color; box-shadow: $shadow-press;
transform: translateX(0); animation: thumb-move $duration $easing infinite;
}
@keyframes thumb-move {
0% { transform: translateX(0); }
33% { transform: translateX(32px); }
66% { transform: translateX(64px); }
100% { transform: translateX(0); }
}
Chỗ dễ hỏng ở đâu? — những cái bẫy
Cả chín hiệu ứng ở đây đều đứng vững trên đặc tả CSS ổn định, nhưng hai kỹ thuật mới nhất còn kén trình duyệt. Hàm light-dark() trên MDN ở mục 04 chạy được từ Chrome 123, Safari 17.5 và Firefox 120 trở lên, còn View Transitions API đứng sau hiệu ứng lan tỏa hình tròn ở mục 03 vẫn kén hơn nữa vì Safari và Firefox mới thêm hỗ trợ gần đây. Nếu trang của bạn còn phải chạy tốt trên trình duyệt cũ hơn các mốc đó, hai mục này cần một màu nền dự phòng khai báo trước color-scheme, để khi light-dark() bị bỏ qua thì chữ không lẫn vào nền. Cách an toàn hơn là bọc phần khai báo light-dark() trong @supports (color: light-dark(white, black)), để trình duyệt chưa hỗ trợ vẫn đọc được cặp màu nền/chữ khai báo thường bên ngoài khối đó, đúng như cách nhiều thư viện component vẫn đang làm khi chưa dám bỏ hẳn phương án cũ.
Cái bẫy thứ hai nằm ở chữ infinite trong animation của cả chín demo — nó có ở đây chỉ để ô xem trước tự lặp, chứ dán y nguyên vào nút thật thì icon sẽ xoay mãi không dừng dù không ai bấm gì. Bản trong file zip đã bỏ infinite và đổi bằng một class .is-dark gắn qua JS khi click, nên animation chỉ chạy đúng một lần theo mỗi lần bấm. Thời lượng animation trong file demo và trong file thật cũng không giống nhau — bản xem trước dùng chung một biến $duration cho cả chín mục để dễ so sánh cạnh nhau, còn bản trong zip đã tách riêng từng mục theo đúng số mili giây ghi trong bảng biến thể của mục đó, nên tốc độ trong lưới xem trước không phải con số bạn nên copy nguyên vào dự án thật, và nếu bạn muốn dừng đọc để mở file ngay thì mật khẩu của zip là kjdwbr3q.
Cái bẫy thứ ba nằm ngay ở phần xem trước phía trên: lưới đó đặt các demo trong khung iframe cố định, quá nhỏ để chờ một cú click thật, nên mỗi ô tự chuyển từ sáng sang tối rồi quay lại sáng theo một vòng lặp hai giây thay cho sự kiện click thật của nó. Bạn chỉ xem chuyển động lặp ở đây thôi; muốn thấy icon mặt trời của mục 01 hay công tắc ba trạng thái của mục 09 phản hồi đúng theo cú bấm thật, hãy mở riêng file vanilla trong tab của bạn rồi tự tay bấm vào nút toggle — vòng lặp trong lưới chỉ là bản thế chỗ tạm thời.
Khả năng tiếp cận
Chín demo đều tôn trọng prefers-reduced-motion, và cả chín cùng dừng ở đúng một trạng thái: mặt sáng đứng yên. Khi animation bị tắt, các lớp mặt tối hay icon mặt trăng vẫn giữ opacity 0 hoặc vị trí ẩn có sẵn trong CSS gốc, không phải một trạng thái lưng chừng giữa sáng và tối. Nút trượt ở mục 02 và công tắc ba ô ở mục 09 khoá lại ở ô đầu tiên bên trái, thẻ lật ở mục 06 khoá ở mặt trước, còn mask nhật thực ở mục 05 giữ nguyên vị trí ẩn phía sau icon mặt trời. Đây không phải chuyển động trang trí có thể bỏ qua, nó chính là trạng thái sáng/tối, nên tắt animation không tắt luôn tính năng — người dùng vẫn đổi được theme bằng cú bấm, chỉ là không thấy bước chuyển. Hướng dẫn prefers-reduced-motion của MDN ghi rõ mức hỗ trợ trình duyệt cho media query này.
@media (prefers-reduced-motion: reduce) {
.toggle__sun, .toggle__moon, .knob, .wipe, .card__inner { animation: none !important; }
.mask, .segmented__thumb { animation: none !important; transform: translateX(0); }
}
Xem thêm các hiệu ứng CSS khác trong chuyên mục CSS, và 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
Css dark mode toggle có cần JavaScript không?
Phần lớn không. Cả chín hiệu ứng chỉ cần một animation hoặc một biến CSS đổi theo class .is-dark, và class đó gắn được bằng một dòng JS ngắn khi click hoặc bằng input[type=checkbox]:checked nếu bạn muốn zero JS. Thư mục react/ trong zip chỉ bọc cùng đoạn SCSS vào component, chứ không dùng JS để tự vẽ chuyển động.
light-dark() dùng được ở trình duyệt nào?
Từ Chrome 123, Safari 17.5 và Firefox 120 trở lên, hàm light-dark() đọc trực tiếp giá trị color-scheme trên phần tử để chọn màu sáng hoặc tối tương ứng. Trang light-dark() của MDN có bảng hỗ trợ trình duyệt chi tiết hơn, và với trình duyệt cũ hơn bạn vẫn cần khai báo màu nền dự phòng.
Chọn hiệu ứng nào cho dark mode toggle của mình?
Tuỳ vị trí đặt nút. Nút trên header hay menu cài đặt hợp với mục 01, 02 hoặc 09 vì người dùng nhìn thấy rõ chính nút gạt, toggle hero trang landing hợp với mục 03 vì hiệu ứng lan tỏa gây ấn tượng ngay từ đầu, còn 04 và 08 hợp khi bạn chỉ cần đổi màu nền và chữ cho cả một thư viện component mà không muốn animate riêng từng nơi.