Ringkasan dasar tentang cara mem-build komponen tombol tema yang adaptif dan mudah diakses.
Dalam postingan ini, saya ingin membagikan pemikiran tentang cara membuat komponen tombol tema gelap dan terang. Coba demo.
Jika Anda lebih suka video, berikut versi YouTube dari postingan ini:
Ringkasan
Situs dapat menyediakan setelan untuk mengontrol skema warna, bukan sepenuhnya mengandalkan preferensi sistem. Artinya, pengguna dapat menjelajah dalam mode selain preferensi sistem mereka. Misalnya, sistem pengguna menggunakan tema terang, tetapi pengguna lebih memilih situs ditampilkan dalam tema gelap.
Ada beberapa pertimbangan engineering web saat mem-build fitur ini. Misalnya, browser harus diberi tahu tentang preferensi sesegera mungkin untuk mencegah warna halaman berkedip, dan kontrol harus disinkronkan terlebih dahulu dengan sistem, lalu mengizinkan pengecualian yang disimpan sisi klien.
Markup
<button>
harus digunakan untuk tombol, karena Anda kemudian akan mendapatkan manfaat dari peristiwa dan fitur interaksi
yang disediakan browser, seperti peristiwa klik dan kemampuan 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 dengan sopan, bukan
aria-live="assertive"
,
memberi tahu pengguna apa yang berubah. Dalam hal 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 berkualitas tinggi dan skalabel dengan markup minimal. Berinteraksi dengan tombol dapat memicu status visual baru untuk vektor, sehingga SVG sangat cocok untuk ikon.
Markup SVG berikut berada di 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 bagus untuk dekorasi visual, seperti ikon
di dalam tombol. Selain atribut viewBox
yang diperlukan pada elemen,
tambahkan tinggi dan lebar karena alasan serupa bahwa gambar harus mendapatkan ukuran
inline.
Matahari
Grafik matahari terdiri dari lingkaran dan garis yang bentuknya sudah tersedia di SVG. <circle>
dipusatkan dengan menetapkan properti cx
dan cy
ke 12,
yang merupakan setengah dari ukuran area pandang (24), lalu diberi radius (r
) dari 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 akhirnya diberi warna isian yang cocok dengan
warna teks halaman dengan
currentColor
.
Sinar matahari
Selanjutnya, garis sinar matahari ditambahkan tepat di bawah lingkaran, di dalam grup
elemen <g>
grup.
<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
goresan garis
ditetapkan. Garis plus bentuk lingkaran menciptakan matahari yang bagus dengan sinar.
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>
Mask dengan SVG
sangat efektif, memungkinkan warna putih dan hitam menghapus atau menyertakan
bagian grafik lain. Ikon matahari akan terhalang oleh bentuk
<circle>
bulan dengan mask SVG, cukup dengan memindahkan bentuk lingkaran ke dalam dan ke luar area
mask.
Apa yang terjadi jika CSS tidak dimuat?
Sebaiknya uji SVG Anda 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 membuat gaya defensif yang baik terhadap gangguan jaringan.
Tata Letak
Komponen tombol tema memiliki sedikit area permukaan, sehingga Anda tidak memerlukan petak atau flexbox untuk tata letak. Sebagai gantinya, transformasi CSS dan pemosisian SVG digunakan.
Gaya
Gaya .theme-toggle
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. Menambahkan gaya kursor untuk pengguna mouse. Tambahkan
touch-action: manipulation
untuk pengalaman sentuhan
yang bereaksi cepat.
Menghapus sorotan semi-transparan yang diterapkan iOS ke tombol. Terakhir, beri
garis batas status fokus ruang bernapas 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;
}
}
Pengukuran adaptif dengan kueri media hover
Ukuran tombol ikon agak kecil di 2rem
, yang tidak masalah bagi pengguna mouse, tetapi
dapat menjadi masalah bagi pointer kasar seperti jari. Buat tombol memenuhi banyak
pedoman ukuran
sentuh
dengan menggunakan kueri media
apung untuk menentukan
peningkatan ukuran.
.theme-toggle {
--size: 2rem;
…
@media (hover: none) {
--size: 48px;
}
}
Gaya SVG matahari dan bulan
Tombol menyimpan aspek interaktif komponen tombol tema, sedangkan SVG di dalamnya akan menyimpan aspek visual dan animasi. Di sinilah ikon dapat dibuat menarik dan diwujudkan.
Tema terang
Agar animasi penskalaan dan rotasi terjadi dari pusat 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 menyediakan
var(--icon-fill)
dan var(--icon-fill-hover)
untuk isi, 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 menghapus sinar matahari, menskalakan 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 belakang kueri media preferensi gerakan pengguna.
Animasi
Tombol harus berfungsi dan memiliki status, tetapi tanpa transisi pada tahap ini. Bagian berikut membahas cara menentukan bagaimana dan apa transisi.
Membagikan kueri media dan mengimpor easing
Untuk memudahkan penempatan transisi dan animasi di balik preferensi gerakan sistem operasi pengguna, plugin PostCSS Media Kustom memungkinkan penggunaan sintaksis spesifikasi CSS yang dibuat draf 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, yang mencapai efek ini dengan easing yang memantul. Sinar matahari akan memantul sedikit saat berputar dan bagian tengah matahari akan 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 Animation di Chrome DevTools, Anda dapat menemukan linimasa untuk transisi animasi. Durasi total animasi, elemen, dan pengaturan waktu easing dapat diperiksa.
Bulan
Posisi terang dan gelap bulan sudah ditetapkan, tambahkan gaya transisi di dalam
kueri media --motionOK
untuk menghidupkannya sekaligus menghormati preferensi
gerakan pengguna.
Pengaturan waktu dengan penundaan dan durasi sangat penting untuk membuat transisi ini bersih. Jika matahari terhalang terlalu awal, misalnya, transisi tidak terasa tertata 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;
}
}
}
}
Memilih pengurangan gerakan
Dalam 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 menetapkan nilai dari penyimpanan lokal.
Pengalaman pemuatan halaman
Penting untuk memastikan tidak ada warna yang berkedip saat halaman dimuat. Jika pengguna dengan
skema warna gelap menunjukkan bahwa mereka lebih memilih terang dengan komponen ini, lalu
memuat ulang halaman, pada awalnya halaman akan gelap, lalu akan berkedip menjadi terang.
Untuk mencegah hal ini, Anda harus menjalankan sejumlah kecil JavaScript pemblokir dengan
tujuan menetapkan atribut HTML data-theme
sesegera mungkin.
<script src="./theme-toggle.js"></script>
Untuk mencapai hal ini, 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 seperlunya, Anda dapat menetapkan atribut HTML
sebelum CSS utama mewarnai halaman, sehingga mencegah flash atau
warna.
JavaScript pertama-tama memeriksa preferensi pengguna di penyimpanan lokal dan fallback 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 menetapkan preferensi pengguna di penyimpanan lokal akan 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 tahap ini adalah status
pemrosesan dokumen HTML. Browser belum mengetahui tombol "#theme-toggle",
karena tag <head>
belum sepenuhnya diuraikan. Namun, browser
memang memiliki
document.firstElementChild
, alias tag <html>
. Fungsi ini mencoba menetapkan keduanya agar tetap sinkron,
tetapi saat pertama kali dijalankan, hanya dapat menetapkan tag HTML. querySelector
tidak akan menemukan apa pun pada awalnya dan operator rantai
opsional
memastikan tidak ada error sintaksis saat tidak ditemukan dan fungsi setAttribute
dicoba dipanggil.
Selanjutnya, fungsi reflectPreference()
tersebut akan langsung dipanggil sehingga dokumen HTML
memiliki atribut data-theme
yang ditetapkan:
reflectPreference()
Tombol masih memerlukan atribut, jadi tunggu peristiwa pemuatan halaman, lalu tombol tersebut akan aman untuk dikueri, menambahkan pemroses, dan menetapkan atribut di:
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 beralih
Saat tombol diklik, tema perlu ditukar, di memori JavaScript dan dalam 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, tombol tema akan berubah agar sesuai dengan preferensi pengguna baru, seolah-olah pengguna telah berinteraksi dengan tombol tema pada saat yang sama dengan tombol sistem.
Mencapai hal ini dengan JavaScript dan peristiwa matchMedia
yang memproses perubahan pada kueri media:
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', ({matches:isDark}) => {
theme.value = isDark ? 'dark' : 'light'
setPreference()
})
Kesimpulan
Setelah Anda tahu cara saya melakukannya, bagaimana Anda melakukannya‽ 🙂
Mari kita diversifikasi pendekatan dan pelajari semua cara untuk mem-build di web. Buat demo, tweet link-nya, 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 vanilla
- @JoshWComeau dengan react