Ringkasan dasar tentang cara membuat komponen pengalih tema adaptif dan mudah diakses.
Dalam postingan ini, saya ingin berbagi pemikiran tentang cara membuat komponen pengalihan tema gelap dan terang. Coba demo.
Jika Anda lebih suka menonton video, berikut versi YouTube dari postingan ini:
Ringkasan
Situs dapat menyediakan setelan untuk mengontrol skema warna, bukan mengandalkan sepenuhnya preferensi sistem. Artinya, pengguna dapat menjelajah dalam mode selain preferensi sistem mereka. Misalnya, sistem pengguna menggunakan tema terang, tetapi pengguna lebih suka situs ditampilkan dalam tema gelap.
Ada beberapa pertimbangan teknik web saat membangun fitur ini. Misalnya, browser harus mengetahui preferensi sesegera mungkin untuk mencegah kilatan warna halaman, dan kontrol harus disinkronkan terlebih dahulu dengan sistem, lalu mengizinkan pengecualian yang disimpan di sisi klien.

Markup
<button>
harus digunakan untuk tombol, karena Anda akan mendapatkan manfaat dari peristiwa dan fitur interaksi yang disediakan browser, seperti peristiwa klik dan fokus.
Tombol
Tombol memerlukan class untuk digunakan dari CSS dan ID untuk digunakan dari JavaScript.
Selain itu, karena konten tombol adalah ikon, bukan teks, tambahkan atribut
title
untuk memberikan informasi tentang tujuan tombol. Terakhir, tambahkan
[aria-label]
untuk menyimpan status tombol ikon, sehingga pembaca layar dapat membagikan status
tema kepada orang-orang yang memiliki gangguan penglihatan.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
>
…
</button>
aria-label
dan aria-live
sopan
Untuk menunjukkan kepada pembaca layar bahwa perubahan pada aria-label
harus diumumkan, tambahkan
aria-live="polite"
ke tombol.
<button
class="theme-toggle"
id="theme-toggle"
title="Toggles light & dark"
aria-label="auto"
aria-live="polite"
>
…
</button>
Penambahan markup ini memberi sinyal kepada pembaca layar untuk menyampaikan perubahan kepada pengguna dengan sopan, bukan
aria-live="assertive"
. Dalam kasus tombol ini, tombol akan mengumumkan "terang"
atau "gelap" bergantung pada apa yang telah menjadi aria-label
.
Ikon grafik vektor skalabel (SVG)
SVG menyediakan cara untuk membuat bentuk yang dapat diskalakan dan berkualitas tinggi dengan markup minimal. Berinteraksi dengan tombol dapat memicu status visual baru untuk vektor, sehingga SVG sangat cocok untuk ikon.
Markup SVG berikut masuk ke dalam <button>
:
<svg class="sun-and-moon" aria-hidden="true" width="24" height="24" viewBox="0 0 24 24">
…
</svg>
aria-hidden
telah ditambahkan ke elemen SVG sehingga pembaca layar tahu untuk mengabaikannya karena
ditandai sebagai presentasi. Hal ini sangat berguna untuk dekorasi visual, seperti ikon di dalam tombol. Selain atribut viewBox
yang diperlukan pada elemen, tambahkan tinggi dan lebar karena alasan serupa dengan gambar yang harus mendapatkan ukuran inline.
Matahari
Grafik matahari terdiri dari lingkaran dan garis yang memiliki bentuk SVG yang praktis. <circle>
dipusatkan dengan menyetel properti cx
dan cy
ke 12, yang merupakan setengah dari ukuran area pandang (24), lalu diberi radius (r
) 6
yang menetapkan ukuran.
<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>
Selain itu, properti mask mengarah ke ID elemen SVG,
yang akan Anda buat berikutnya, dan terakhir diberi warna pengisi yang cocok dengan
warna teks halaman dengan
currentColor
.
Sinar matahari
Selanjutnya, garis sinar matahari ditambahkan tepat di bawah lingkaran, di dalam elemen grup <g>
.
<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>
Kali ini, bukan nilai
fill yang
currentColor
, setiap
stroke garis
ditetapkan. Garis dan bentuk lingkaran menciptakan matahari yang indah dengan sinarnya.
Bulan
Untuk menciptakan ilusi transisi yang lancar antara terang (matahari) dan gelap (bulan), bulan adalah augmentasi ikon matahari, menggunakan mask 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>

Masker dengan SVG sangat efektif, memungkinkan warna putih dan hitam menghapus atau menyertakan bagian dari grafik lain. Ikon matahari akan tertutup oleh bentuk bulan
<circle>
dengan mask SVG, hanya dengan memindahkan bentuk lingkaran ke dalam dan ke luar area
mask.
Apa yang terjadi jika CSS tidak dimuat?

Anda dapat menguji SVG seolah-olah CSS tidak dimuat untuk memastikan hasilnya tidak terlalu besar atau menyebabkan masalah tata letak. Atribut tinggi dan lebar inline pada
SVG ditambah penggunaan currentColor
memberikan aturan gaya minimal untuk digunakan browser
jika CSS tidak dimuat. Hal ini menghasilkan gaya defensif yang bagus terhadap turbulensi jaringan.
Tata Letak
Komponen pengalihan tema memiliki area permukaan yang kecil, sehingga Anda tidak memerlukan petak atau flexbox untuk tata letak. Sebagai gantinya, posisi SVG dan transformasi CSS digunakan.
Gaya
.theme-toggle
gaya
Elemen <button>
adalah penampung untuk bentuk dan gaya ikon. Konteks induk ini akan menyimpan warna dan ukuran adaptif untuk diteruskan ke SVG.
Tugas pertama adalah membuat tombol menjadi lingkaran dan menghapus gaya tombol default:
.theme-toggle {
--size: 2rem;
background: none;
border: none;
padding: 0;
inline-size: var(--size);
block-size: var(--size);
aspect-ratio: 1;
border-radius: 50%;
}
Selanjutnya, tambahkan beberapa gaya interaksi. Tambahkan gaya kursor untuk pengguna mouse. Menambahkan
touch-action: manipulation
untuk pengalaman sentuhan yang bereaksi cepat.
Menghapus tanda semi-transparan yang diterapkan iOS ke tombol. Terakhir, beri ruang yang cukup untuk garis batas status fokus dari tepi elemen:
.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 di dalam tombol juga memerlukan beberapa gaya. SVG harus sesuai dengan ukuran tombol dan, untuk kelembutan visual, bulatkan ujung garis:
.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;
}
}
Penyesuaian ukuran adaptif dengan kueri media hover
Ukuran tombol ikon agak kecil, yaitu 2rem
, yang tidak masalah bagi pengguna mouse, tetapi
dapat menyulitkan pointer kasar seperti jari. Buat tombol memenuhi banyak
pedoman ukuran sentuh
dengan menggunakan kueri media
saat mengarahkan kursor untuk menentukan
peningkatan ukuran.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Gaya SVG matahari dan bulan
Tombol ini menyimpan aspek interaktif dari komponen pengalih tema, sedangkan SVG di dalamnya akan menyimpan aspek visual dan animasi. Di sinilah ikon dapat dibuat indah dan diwujudkan.
Tema terang

Agar animasi skala dan rotasi terjadi dari tengah bentuk SVG, tetapkan
transform-origin: center center
-nya. Warna adaptif yang disediakan oleh
tombol digunakan di sini oleh bentuk. Bulan dan matahari menggunakan tombol yang disediakan
var(--icon-fill)
dan var(--icon-fill-hover)
untuk pengisiannya, sedangkan
sinar matahari menggunakan variabel untuk goresan.
.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);
}
}
}
Tema gelap

Gaya bulan perlu menghilangkan sinar matahari, memperbesar lingkaran matahari, dan memindahkan mask lingkaran.
.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;
}
}
}
}
Perhatikan bahwa tema gelap tidak memiliki perubahan atau transisi warna. Komponen tombol induk memiliki warna, yang sudah adaptif dalam konteks gelap dan terang. Informasi transisi harus berada di balik kueri media preferensi gerakan pengguna.
Animasi
Tombol harus berfungsi dan memiliki status, tetapi tanpa transisi pada titik ini. Bagian berikut membahas cara menentukan bagaimana dan apa transisinya.
Membagikan kueri media dan mengimpor easing
Untuk mempermudah penempatan transisi dan animasi di belakang preferensi gerakan sistem operasi pengguna, plugin PostCSS CustomMedia memungkinkan penggunaan sintaksis spesifikasi CSS yang drafnya telah dibuat untuk variabel kueri media:
@custom-media --motionOK (prefers-reduced-motion: no-preference);
/* usage example */
@media (--motionOK) {
.sun {
transition: transform .5s var(--ease-elastic-3);
}
}
Untuk easing CSS yang unik dan mudah digunakan, impor bagian easings dari Open Props:
@import "https://unpkg.com/open-props/easings.min.css";
/* usage example */
.sun {
transition: transform .5s var(--ease-elastic-3);
}
Matahari
Transisi matahari akan lebih ceria daripada bulan, sehingga efek ini dapat dicapai dengan easing yang memantul. Sinar matahari harus memantul sedikit saat berputar dan pusat matahari harus memantul sedikit saat diskalakan.
Gaya default (tema terang) menentukan transisi dan gaya tema gelap menentukan penyesuaian untuk transisi ke terang:
.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;
}
}
}
}
Di panel Animasi di Chrome DevTools, Anda dapat menemukan linimasa untuk transisi animasi. Durasi total animasi, elemen, dan waktu easing dapat diperiksa.


Bulan
Posisi terang dan gelap bulan sudah ditetapkan, tambahkan gaya transisi di dalam kueri media --motionOK
untuk membuatnya lebih hidup sekaligus menghormati preferensi gerakan pengguna.
Pengaturan waktu dengan penundaan dan durasi sangat penting untuk membuat transisi ini berjalan lancar. Jika gerhana matahari terjadi terlalu awal, misalnya, transisinya tidak terasa teratur atau menyenangkan, tetapi terasa kacau.
.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;
}
}
}
}


Lebih memilih gerakan yang dikurangi
Di sebagian besar Tantangan GUI, saya mencoba mempertahankan beberapa animasi, seperti cross fade opasitas, untuk pengguna yang lebih memilih gerakan yang dikurangi. Namun, komponen ini terasa lebih baik dengan perubahan status instan.
JavaScript
Ada banyak pekerjaan untuk JavaScript dalam komponen ini, mulai dari mengelola informasi ARIA untuk pembaca layar hingga mendapatkan dan menyetel nilai dari local storage.
Pengalaman pemuatan halaman
Tidak boleh ada warna yang berkedip saat halaman dimuat. Jika pengguna dengan skema warna gelap menunjukkan bahwa mereka lebih menyukai warna terang dengan komponen ini, lalu memuat ulang halaman, pada awalnya halaman akan berwarna gelap, lalu akan berkedip menjadi terang.
Untuk mencegah hal ini, sejumlah kecil JavaScript pemblokiran harus dijalankan dengan
tujuan untuk menetapkan atribut HTML data-theme
sedini mungkin.
<script src="./theme-toggle.js"></script>
Untuk mencapainya, tag <script>
biasa dalam dokumen <head>
dimuat terlebih dahulu, sebelum markup CSS atau <body>
. Saat menemukan skrip yang tidak ditandai seperti ini, browser akan menjalankan kode dan mengeksekusinya sebelum HTML lainnya. Dengan menggunakan momen pemblokiran ini secara hemat, Anda dapat menetapkan atribut HTML sebelum CSS utama merender halaman, sehingga mencegah tampilan sekilas atau warna yang tidak diinginkan.
JavaScript pertama-tama memeriksa preferensi pengguna di penyimpanan lokal dan melakukan penggantian untuk memeriksa preferensi sistem jika tidak ada yang ditemukan di penyimpanan:
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'
}
Fungsi untuk menyetel preferensi pengguna di penyimpanan lokal diuraikan berikutnya:
const setPreference = () => {
localStorage.setItem(storageKey, theme.value)
reflectPreference()
}
Diikuti dengan fungsi untuk mengubah dokumen dengan preferensi.
const reflectPreference = () => {
document.firstElementChild
.setAttribute('data-theme', theme.value)
document
.querySelector('#theme-toggle')
?.setAttribute('aria-label', theme.value)
}
Hal penting yang perlu diperhatikan pada saat ini adalah status parsing dokumen HTML. Browser belum mengetahui tombol "#theme-toggle", karena tag <head>
belum diuraikan sepenuhnya. Namun, browser memiliki
document.firstElementChild
, alias tag <html>
. Fungsi ini mencoba menyetel keduanya agar tetap disinkronkan,
tetapi pada saat pertama kali dijalankan, hanya tag HTML yang dapat disetel. querySelector
tidak akan menemukan apa pun pada awalnya dan operator
penggabungan opsional
memastikan tidak ada error sintaksis saat tidak ditemukan dan fungsi setAttribute
dicoba dipanggil.
Selanjutnya, fungsi reflectPreference()
tersebut langsung dipanggil sehingga atribut data-theme
dokumen HTML ditetapkan:
reflectPreference()
Tombol masih memerlukan atribut, jadi tunggu peristiwa pemuatan halaman, lalu tombol akan aman untuk dikueri, ditambahkan pemroses, dan ditetapkan atributnya:
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)
}
Pengalaman peralihan
Saat tombol diklik, tema perlu ditukar, di memori JavaScript dan di dokumen. Nilai tema saat ini perlu diperiksa dan keputusan dibuat tentang status barunya. Setelah status baru ditetapkan, simpan dan perbarui dokumen:
const onClick = () => {
theme.value = theme.value === 'light'
? 'dark'
: 'light'
setPreference()
}
Menyinkronkan dengan sistem
Yang unik dari tombol tema ini adalah sinkronisasi dengan preferensi sistem saat berubah. Jika pengguna mengubah preferensi sistemnya saat halaman dan komponen ini terlihat, peralihan tema akan berubah agar sesuai dengan preferensi pengguna baru, seolah-olah pengguna telah berinteraksi dengan peralihan tema pada saat yang sama dengan peralihan sistem.
Lakukan ini dengan JavaScript dan
matchMedia
peristiwa yang memproses perubahan pada kueri media:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
Kesimpulan
Sekarang setelah Anda tahu cara saya melakukannya, bagaimana Anda‽ 🙂
Mari kita diversifikasi pendekatan kita dan pelajari semua cara untuk membangun di web. Buat demo, tweet linknya kepada saya, dan saya akan menambahkannya ke bagian remix komunitas di bawah.
Remix komunitas
- @NathanG di Codepen dengan Vue
- @ShadowShahriar di Codepen
- @tomayac sebagai elemen kustom
- @bramus dengan JavaScript standar
- @JoshWComeau dengan react