Membuat komponen switch tema

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.

Ukuran tombol Demo diperbesar agar mudah dilihat

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.

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

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

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

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

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

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?

Screenshot tombol browser biasa dengan ikon matahari di dalamnya.

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

ALT_TEXT_HERE

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

ALT_TEXT_HERE

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.

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

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

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