Thông tin tổng quan cơ bản về cách tạo một thành phần chuyển đổi giao diện dễ tiếp cận và thích ứng.
Trong bài đăng này, tôi muốn chia sẻ suy nghĩ về cách tạo một thành phần chuyển đổi giao diện tối và sáng. Dùng thử bản minh hoạ.
Nếu bạn thích xem video, thì đây là phiên bản video của bài đăng này trên YouTube:
Tổng quan
Một trang web có thể cung cấp các chế độ cài đặt để kiểm soát bảng phối màu thay vì hoàn toàn dựa vào lựa chọn ưu tiên của hệ thống. Điều này có nghĩa là người dùng có thể duyệt web ở một chế độ khác với chế độ ưu tiên của hệ thống. Ví dụ: hệ thống của người dùng đang ở chế độ sáng, nhưng người dùng muốn trang web hiển thị ở chế độ tối.
Có một số yếu tố cần cân nhắc về kỹ thuật web khi xây dựng tính năng này. Ví dụ: trình duyệt phải nhận biết được lựa chọn ưu tiên này càng sớm càng tốt để ngăn tình trạng nhấp nháy màu trang và chế độ kiểm soát cần đồng bộ hoá với hệ thống trước rồi mới cho phép các trường hợp ngoại lệ được lưu trữ phía máy khách.

Markup (note: đây là tên ứng dụng)
Bạn nên dùng <button>
cho nút bật/tắt, vì khi đó bạn sẽ được hưởng lợi từ các sự kiện và tính năng tương tác do trình duyệt cung cấp, chẳng hạn như sự kiện nhấp chuột và khả năng lấy tiêu điểm.
Nút
Nút này cần có một lớp để sử dụng từ CSS và một mã nhận dạng để sử dụng từ JavaScript.
Ngoài ra, vì nội dung của nút là một biểu tượng chứ không phải văn bản, hãy thêm thuộc tính title để cung cấp thông tin về mục đích của nút. Cuối cùng, hãy thêm một [aria-label]
để giữ trạng thái của nút biểu tượng, nhờ đó trình đọc màn hình có thể chia sẻ trạng thái của giao diện với những người khiếm thị.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
>
…
</button>
aria-label
và aria-live
lịch sự
Để cho trình đọc màn hình biết rằng các thay đổi đối với aria-label
cần được thông báo, hãy thêm aria-live="polite"
vào nút.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
…
</button>
Việc bổ sung mã đánh dấu này sẽ báo hiệu cho trình đọc màn hình biết rằng thay vì aria-live="assertive"
, hãy cho người dùng biết những gì đã thay đổi. Trong trường hợp này, nút sẽ thông báo "sáng" hoặc "tối" tuỳ thuộc vào trạng thái của aria-label
.
Biểu tượng đồ hoạ vectơ có thể mở rộng (SVG)
SVG cung cấp một cách để tạo các hình dạng có thể mở rộng, chất lượng cao với mức đánh dấu tối thiểu. Tương tác với nút có thể kích hoạt các trạng thái trực quan mới cho vectơ, giúp SVG trở thành lựa chọn lý tưởng cho biểu tượng.
Mã đánh dấu SVG sau đây nằm bên trong <button>
:
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
…
</svg>
aria-hidden
đã được thêm vào phần tử SVG để trình đọc màn hình biết bỏ qua phần tử này vì phần tử này được đánh dấu là trình bày. Đây là cách hay để trang trí bằng hình ảnh, chẳng hạn như biểu tượng bên trong một nút. Ngoài thuộc tính viewBox
bắt buộc trên phần tử, hãy thêm chiều cao và chiều rộng cho các lý do tương tự mà hình ảnh phải có kích thước nội tuyến.
Mặt trời
Hình mặt trời bao gồm một vòng tròn và các đường thẳng. SVG có các hình dạng thuận tiện cho việc này. <circle>
được căn giữa bằng cách đặt các thuộc tính cx
và cy
thành 12, là một nửa kích thước khung nhìn (24), sau đó được đặt bán kính (r
) là 6
để đặt kích thước.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
</svg>
Ngoài ra, thuộc tính mặt nạ trỏ đến mã nhận dạng của phần tử SVG, bạn sẽ tạo phần tử này ở bước tiếp theo và cuối cùng là chỉ định màu tô khớp với màu văn bản của trang bằng currentColor
.
Ánh nắng mặt trời
Tiếp theo, các đường tia nắng được thêm ngay bên dưới hình tròn, bên trong một phần tử nhóm <g>
nhóm.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</g>
</svg>
Lần này, thay vì giá trị fill là currentColor
, stroke của mỗi dòng sẽ được đặt. Các đường thẳng và hình tròn tạo nên một mặt trời đẹp có các tia sáng.
Mặt trăng
Để tạo ảo giác về sự chuyển đổi liền mạch giữa ánh sáng (mặt trời) và bóng tối (mặt trăng), mặt trăng là một phiên bản tăng cường của biểu tượng mặt trời, sử dụng mặt nạ SVG.
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
<circle class="sun" cx="12" cy="12" r="6" mask="url(#moon-mask)" fill="currentColor" />
<g class="sun-beams" stroke="currentColor">
…
</g>
<mask class="moon" id="moon-mask">
<rect x="0" y="0" width="100%" height="100%" fill="white" />
<circle cx="24" cy="10" r="6" fill="black" />
</mask>
</svg>

Mặt nạ có SVG rất mạnh mẽ, cho phép màu trắng và đen loại bỏ hoặc đưa vào các phần của một đồ hoạ khác. Biểu tượng mặt trời sẽ bị che khuất bởi hình dạng mặt trăng <circle>
bằng mặt nạ SVG, chỉ bằng cách di chuyển hình tròn vào và ra khỏi vùng mặt nạ.
Điều gì sẽ xảy ra nếu CSS không tải?

Bạn nên kiểm thử SVG như thể CSS không tải để đảm bảo kết quả không quá lớn hoặc gây ra vấn đề về bố cục. Các thuộc tính chiều cao và chiều rộng nội tuyến trên SVG cùng với việc sử dụng currentColor
cung cấp các quy tắc kiểu tối thiểu để trình duyệt sử dụng nếu CSS không tải. Điều này tạo ra những phong cách phòng thủ hiệu quả trước sự hỗn loạn của mạng.
Bố cục
Thành phần chuyển đổi giao diện có diện tích nhỏ, vì vậy bạn không cần lưới hoặc flexbox cho bố cục. Thay vào đó, hệ thống sẽ sử dụng tính năng định vị SVG và biến đổi CSS.
Kiểu
.theme-toggle
kiểu
Phần tử <button>
là vùng chứa cho các hình dạng và kiểu biểu tượng. Ngữ cảnh mẹ này sẽ giữ các màu và kích thước thích ứng để truyền xuống SVG.
Nhiệm vụ đầu tiên là biến nút thành một hình tròn và xoá các kiểu nút mặc định:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
}
Tiếp theo, hãy thêm một số kiểu tương tác. Thêm kiểu con trỏ cho người dùng chuột. Thêm touch-action: manipulation
để có trải nghiệm cảm ứng phản hồi nhanh.
Xoá phần đánh dấu bán trong suốt mà iOS áp dụng cho các nút. Cuối cùng, hãy tạo khoảng trống cho đường viền trạng thái tiêu điểm so với cạnh của phần tử:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
}
SVG bên trong nút cũng cần một số kiểu. SVG phải phù hợp với kích thước của nút và để có độ mềm mại về mặt hình ảnh, hãy làm tròn các đầu dòng:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
outline-offset: 5px;
& > svg {
inline-size: 100%;
block-size: 100%;
stroke-linecap: round;
}
}
Điều chỉnh kích thước thích ứng bằng truy vấn nội dung nghe nhìn hover
Kích thước nút biểu tượng hơi nhỏ ở mức 2rem
, phù hợp với người dùng chuột nhưng có thể gây khó khăn cho con trỏ thô như ngón tay. Hãy làm cho nút đáp ứng nhiều nguyên tắc về kích thước cảm ứng bằng cách sử dụng truy vấn phương tiện di chuột để chỉ định mức tăng kích thước.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Kiểu SVG của mặt trời và mặt trăng
Nút này sẽ giữ các khía cạnh tương tác của thành phần chuyển đổi giao diện, trong khi SVG bên trong sẽ giữ các khía cạnh trực quan và có hiệu ứng động. Đây là nơi biểu tượng có thể trở nên đẹp mắt và sống động.
Giao diện sáng

Để ảnh động thu phóng và xoay diễn ra từ tâm của các hình dạng SVG, hãy đặt transform-origin: center center
cho các hình dạng đó. Các hình dạng ở đây sử dụng màu thích ứng do nút cung cấp. Mặt trăng và mặt trời sử dụng nút var(--icon-fill)
và var(--icon-fill-hover)
được cung cấp cho phần tô màu, trong khi các tia nắng sử dụng các biến cho nét vẽ.
.sun-and-moon {
& > :is(.moon, .sun, .sun-beams) {
transform-origin: center center;
}
& > :is(.moon, .sun) {
fill: var(--icon-fill);
@nest .theme-toggle:is(:hover, :focus-visible) > & {
fill: var(--icon-fill-hover);
}
}
& > .sun-beams {
stroke: var(--icon-fill);
stroke-width: 2px;
@nest .theme-toggle:is(:hover, :focus-visible) & {
stroke: var(--icon-fill-hover);
}
}
}
Giao diện tối

Các kiểu mặt trăng cần loại bỏ tia nắng, tăng kích thước vòng tròn mặt trời và di chuyển mặt nạ vòng tròn.
.sun-and-moon {
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
}
& > .sun-beams {
opacity: 0;
}
& > .moon > circle {
transform: translateX(-7px);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
}
}
}
}
Lưu ý rằng giao diện tối không có sự thay đổi hoặc chuyển đổi màu sắc. Thành phần nút mẹ sở hữu các màu, trong đó các màu này đã thích ứng trong ngữ cảnh tối và sáng. Thông tin chuyển đổi phải nằm sau truy vấn nội dung nghe nhìn về lựa chọn ưu tiên chuyển động của người dùng.
Hoạt ảnh
Nút này phải hoạt động được và có trạng thái nhưng không có hiệu ứng chuyển đổi tại thời điểm này. Các phần sau đây đều nói về cách xác định cách và những gì chuyển đổi.
Chia sẻ truy vấn nội dung nghe nhìn và nhập hiệu ứng chuyển động
Để dễ dàng đặt các hiệu ứng chuyển cảnh và hoạt ảnh theo lựa chọn ưu tiên về chuyển động của hệ điều hành người dùng, trình bổ trợ PostCSS Custom Media cho phép sử dụng cú pháp đặc tả CSS nháp cho các biến truy vấn nội dung đa phương tiện:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
Để có các đường cong tăng tốc CSS độc đáo và dễ sử dụng, hãy nhập phần easings của Open Props:
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
Mặt trời
Hiệu ứng chuyển đổi mặt trời sẽ sinh động hơn hiệu ứng chuyển đổi mặt trăng, đạt được hiệu ứng này bằng cách sử dụng các đường cong tăng tốc và giảm tốc linh hoạt. Các tia nắng nên nảy lên một chút khi xoay và tâm của mặt trời nên nảy lên một chút khi mở rộng.
Các kiểu mặc định (giao diện sáng) xác định các hiệu ứng chuyển đổi và các kiểu giao diện tối xác định các chế độ tuỳ chỉnh cho hiệu ứng chuyển đổi sang giao diện sáng:
.sun-and-moon {
@media (--motionOK) {
& > .sun {
transition: transform .5s var(--ease-elastic-3);
}
& > .sun-beams {
transition:
transform .5s var(--ease-elastic-4),
opacity .5s var(--ease-3)
;
}
@nest [data-theme="dark"] & {
& > .sun {
transform: scale(1.75);
transition-timing-function: var(--ease-3);
transition-duration: .25s;
}
& > .sun-beams {
transform: rotateZ(-25deg);
transition-duration: .15s;
}
}
}
}
Trong bảng điều khiển Ảnh động trong Công cụ của Chrome cho nhà phát triển, bạn có thể tìm thấy một dòng thời gian cho các hiệu ứng chuyển đổi ảnh động. Bạn có thể kiểm tra thời lượng của toàn bộ ảnh động, các phần tử và thời gian giảm tốc.


Mặt trăng
Vị trí ánh sáng và bóng tối của mặt trăng đã được đặt, hãy thêm các kiểu chuyển đổi bên trong truy vấn nội dung nghe nhìn --motionOK
để làm cho vị trí này trở nên sống động trong khi vẫn tôn trọng lựa chọn ưu tiên về chuyển động của người dùng.
Thời gian có độ trễ và thời lượng là yếu tố quan trọng để quá trình chuyển đổi này diễn ra suôn sẻ. Nếu nhật thực xảy ra quá sớm, chẳng hạn như hiệu ứng chuyển đổi không có vẻ được dàn dựng hoặc vui tươi mà lại có vẻ hỗn loạn.
.sun-and-moon {
@media (--motionOK) {
& .moon > circle {
transform: translateX(-7px);
transition: transform .25s var(--ease-out-5);
@supports (cx: 1px) {
transform: translateX(0);
cx: 17px;
transition: cx .25s var(--ease-out-5);
}
}
@nest [data-theme="dark"] & {
& > .moon > circle {
transition-delay: .25s;
transition-duration: .5s;
}
}
}
}


Ưu tiên giảm chuyển động
Trong hầu hết các Thử thách về giao diện người dùng đồ hoạ, tôi cố gắng giữ lại một số ảnh động, chẳng hạn như hiệu ứng mờ dần, cho những người dùng thích chế độ giảm chuyển động. Tuy nhiên, thành phần này hoạt động tốt hơn khi có các thay đổi về trạng thái tức thì.
JavaScript
JavaScript có rất nhiều việc phải làm trong thành phần này, từ việc quản lý thông tin ARIA cho trình đọc màn hình đến việc nhận và đặt các giá trị từ bộ nhớ cục bộ.
Trải nghiệm tải trang
Điều quan trọng là không có màu nào nhấp nháy khi tải trang. Nếu người dùng có bảng phối màu tối cho biết họ thích màu sáng với thành phần này, sau đó tải lại trang, thì ban đầu trang sẽ có màu tối rồi chuyển sang màu sáng.
Để ngăn chặn điều này, bạn cần chạy một lượng nhỏ JavaScript chặn với mục tiêu là đặt thuộc tính HTML data-theme
càng sớm càng tốt.
<script src="./theme-toggle.js"></script>
Để đạt được điều này, thẻ <script>
thuần tuý trong tài liệu <head>
sẽ được tải trước, trước mọi CSS hoặc đánh dấu <body>
. Khi gặp một tập lệnh chưa được đánh dấu như thế này, trình duyệt sẽ chạy mã và thực thi mã đó trước phần còn lại của HTML. Khi sử dụng khoảnh khắc chặn này một cách tiết kiệm, bạn có thể đặt thuộc tính HTML trước khi CSS chính vẽ trang, nhờ đó ngăn chặn tình trạng nhấp nháy hoặc màu sắc.
Trước tiên, JavaScript sẽ kiểm tra lựa chọn ưu tiên của người dùng trong bộ nhớ cục bộ và dự phòng để kiểm tra lựa chọn ưu tiên của hệ thống nếu không tìm thấy lựa chọn ưu tiên nào trong bộ nhớ:
const storageKey = 'theme-preference'
const getColorPreference = () => {
if (localStorage.getItem(storageKey))
return localStorage.getItem(storageKey)
else
return window.matchMedia('(prefers-color-scheme: dark)').matches
? 'dark'
: 'light'
}
Tiếp theo, một hàm để đặt lựa chọn ưu tiên của người dùng trong bộ nhớ cục bộ sẽ được phân tích cú pháp:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
Tiếp theo là một hàm để sửa đổi tài liệu theo các lựa chọn ưu tiên.
const reflectPreference = () => {
document.firstElementChild
.setAttribute('data-theme', theme.value)
document
.querySelector('#theme-toggle')
?.setAttribute('aria-label', theme.value)
}
Một điều quan trọng cần lưu ý tại thời điểm này là trạng thái phân tích cú pháp tài liệu HTML. Trình duyệt chưa biết về nút "#theme-toggle" vì thẻ <head>
chưa được phân tích cú pháp hoàn toàn. Tuy nhiên, trình duyệt có document.firstElementChild
, còn gọi là thẻ <html>
. Hàm này cố gắng đặt cả hai để giữ chúng đồng bộ, nhưng trong lần chạy đầu tiên, hàm này sẽ chỉ có thể đặt thẻ HTML. querySelector
sẽ không tìm thấy gì lúc đầu và toán tử chuỗi tuỳ chọn đảm bảo không có lỗi cú pháp khi không tìm thấy và hàm setAttribute được cố gắng gọi.
Tiếp theo, hàm reflectPreference()
đó sẽ được gọi ngay lập tức để tài liệu HTML có thuộc tính data-theme
được đặt:
reflectPreference()
Nút này vẫn cần thuộc tính, vì vậy, hãy đợi sự kiện tải trang, sau đó bạn có thể truy vấn, thêm trình nghe và đặt thuộc tính một cách an toàn trên:
window.onload = () => {
// set on load so screen readers can get the latest value on the button
reflectPreference()
// now this script can find and listen for clicks on the control
document
.querySelector('#theme-toggle')
.addEventListener('click', onClick)
}
Trải nghiệm chuyển đổi
Khi người dùng nhấp vào nút, giao diện cần được hoán đổi trong bộ nhớ JavaScript và trong tài liệu. Bạn cần kiểm tra giá trị giao diện hiện tại và đưa ra quyết định về trạng thái mới của giá trị đó. Sau khi bạn đặt trạng thái mới, hãy lưu trạng thái đó và cập nhật tài liệu:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
Đồng bộ hoá với hệ thống
Điểm đặc biệt của nút chuyển đổi giao diện này là khả năng đồng bộ hoá với lựa chọn ưu tiên của hệ thống khi lựa chọn này thay đổi. Nếu người dùng thay đổi lựa chọn ưu tiên của hệ thống trong khi một trang và thành phần này đang hiển thị, thì công tắc giao diện sẽ thay đổi để phù hợp với lựa chọn ưu tiên mới của người dùng, như thể người dùng đã tương tác với công tắc giao diện cùng lúc với công tắc hệ thống.
Đạt được điều này bằng JavaScript và một sự kiện matchMedia
theo dõi các thay đổi đối với một truy vấn nội dung nghe nhìn:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
Kết luận
Giờ bạn đã biết cách tôi làm, vậy bạn sẽ làm như thế nào‽ 🙂
Hãy đa dạng hoá các phương pháp và tìm hiểu tất cả các cách để xây dựng trên web. Hãy tạo một bản minh hoạ, gửi đường liên kết cho tôi qua Twitter và tôi sẽ thêm bản minh hoạ đó vào phần bản phối lại của cộng đồng bên dưới!
Bản phối lại của cộng đồng
- @NathanG trên Codepen với Vue
- @ShadowShahriar trên Codepen
- @tomayac dưới dạng một phần tử tuỳ chỉnh
- @bramus bằng JavaScript thuần
- @JoshWComeau với react