Membuat komponen switch tema

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.

Ukuran tombol Demo diperbesar agar mudah terlihat

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.

Diagram menampilkan pratinjau pemuatan halaman JavaScript dan peristiwa interaksi dokumen untuk menunjukkan secara keseluruhan bahwa ada 4 jalur untuk menyetel tema

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

Ikon matahari ditampilkan dengan sinar matahari yang memudar dan panah merah muda
  menunjuk ke lingkaran di tengah.

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

Ikon matahari yang ditampilkan dengan pusat matahari yang memudar dan panah merah muda
  yang menunjuk ke 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>
Grafik dengan tiga lapisan vertikal untuk membantu menunjukkan cara kerja penyamaran. Lapisan
atas adalah persegi putih dengan lingkaran hitam. Lapisan tengah adalah ikon matahari.
Lapisan bawah diberi label sebagai hasil dan menampilkan ikon matahari dengan
potongan di tempat lingkaran hitam lapisan atas berada.

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?

Screenshot tombol browser biasa dengan ikon matahari di dalamnya.

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

ALT_TEXT_HERE

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

ALT_TEXT_HERE

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.

Transisi terang ke gelap
Transisi gelap ke terang

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;
      }
    }
  }
}
Transisi terang ke gelap
Transisi gelap ke terang

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()
  })
Mengubah preferensi sistem MacOS akan mengubah status tombol tema

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