Membuat komponen switch tema

Ringkasan dasar tentang cara membangun komponen pengalihan tema yang adaptif dan mudah diakses.

Dalam postingan ini, saya ingin berbagi pemikiran tentang cara membangun komponen tombol akses tema gelap dan terang. Coba demonya.

Ukuran tombol Demo dinaikkan untuk visibilitas yang mudah

Jika Anda lebih suka video, berikut versi YouTube postingan ini:

Ringkasan

Situs dapat menyediakan setelan untuk mengontrol skema warna, bukan hanya mengandalkan preferensi sistem sepenuhnya. 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 rekayasa web saat membuat fitur ini. Misalnya, browser harus menyadari preferensi sesegera mungkin untuk mencegah warna halaman berkedip, dan kontrol harus disinkronkan terlebih dahulu dengan sistem kemudian mengizinkan pengecualian yang disimpan dari sisi klien.

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

Markup

<button> harus digunakan untuk tombol tersebut, karena Anda kemudian akan mendapatkan manfaat dari fitur dan peristiwa 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 pengguna 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 tersebut.

<button 
  class="theme-toggle" 
  id="theme-toggle" 
  title="Toggles light & dark" 
  aria-label="auto" 
  aria-live="polite"
>
  …
</button>

Markup ini memberi sinyal kepada pembaca layar untuk dengan sopan, bukan aria-live="assertive", memberi tahu pengguna apa yang berubah. Dalam kasus tombol ini, tombol akan mengumumkan "terang" atau "gelap" bergantung pada apa yang menjadi aria-label.

Ikon Scalable Vector Graphics (SVG)

SVG menyediakan cara untuk membuat bentuk yang skalabel dan berkualitas tinggi dengan markup minimal. Berinteraksi dengan tombol dapat memicu status visual baru untuk vektor, sehingga SVG sangat bagus 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. Ini bagus untuk dilakukan untuk dekorasi visual, seperti ikon di dalam tombol. Selain atribut viewBox yang diperlukan pada elemen, tambahkan tinggi dan lebar untuk alasan serupa bahwa gambar harus mendapatkan ukuran inline.

Matahari

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

Grafik matahari terdiri dari lingkaran dan garis-garis yang bentuk SVG mudahnya dalam hal ini. <circle> dipusatkan dengan menetapkan 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 selanjutnya, dan terakhir diberi warna isian yang cocok dengan warna teks halaman dengan currentColor.

Cahaya matahari

Ikon matahari yang ditampilkan dengan pusat matahari memudar dan panah hotpink
  yang menunjuk ke sinar matahari.

Selanjutnya, garis sinar matahari ditambahkan tepat di bawah lingkaran, di dalam grup 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, alih-alih nilai fill yang menjadi currentColor, stroke setiap baris akan ditetapkan. Garis-garis ditambah bentuk lingkaran menciptakan matahari yang indah dengan balok-baloknya.

Bulan

Untuk menciptakan ilusi transisi yang lancar antara terang (matahari) dan gelap (bulan), bulan merupakan augmentasi ikon matahari, menggunakan topeng 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>
Gambar dengan tiga lapisan vertikal untuk membantu menunjukkan cara kerja masking. Lapisan
atas adalah persegi putih dengan lingkaran hitam. Lapisan tengah adalah ikon matahari.
Lapisan paling bawah diberi label sebagai hasil dan menampilkan ikon matahari dengan
potongan di tempat lingkaran hitam lapisan atas berada.

Mask dengan SVG sangat efektif, sehingga warna putih dan hitam dapat menghapus atau menyertakan bagian dari grafis lain. Ikon matahari akan dihalangi 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 dapat dimuat?

Screenshot tombol browser biasa dengan ikon matahari di dalamnya.

Sebaiknya uji 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 browser yang digunakan jika CSS tidak dimuat. Cara ini menghasilkan gaya defensif yang bagus terhadap turbulensi jaringan.

Tata Letak

Komponen tombol tema memiliki sedikit area permukaan, sehingga Anda tidak memerlukan petak atau flexbox untuk tata letak. Sebagai gantinya, pemosisian 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. Tambahkan touch-action: manipulation untuk pengalaman sentuh reaksi cepat. Hapus sorotan semi-transparan yang diterapkan iOS ke tombol. Terakhir, berikan status fokus untuk menguraikan beberapa ruang pernapasan 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 membutuhkan beberapa gaya. SVG harus pas dengan ukuran tombol dan, untuk kelembutan visual, bulatkan ujung baris:

.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;
  }
}

Ukuran adaptif dengan kueri media hover

Ukuran tombol ikon agak kecil di 2rem, yang bagus bagi pengguna mouse, tetapi mungkin sulit untuk pointer kasar seperti jari. Buat tombol sesuai dengan banyak pedoman ukuran sentuh dengan menggunakan kueri media arahkan kursor untuk menentukan peningkatan ukuran.

.theme-toggle {
  --size: 2rem;
  …
  
  @media (hover: none) {
    --size: 48px;
  }
}

Gaya SVG matahari dan bulan

Tombol ini menampung aspek interaktif dari komponen switch tema, sementara SVG di dalamnya akan menyimpan aspek visual dan animasi. Di sinilah ikon dapat dibuat indah dan menjadi hidup.

Tema terang

ALT_TEXT_HERE

Agar animasi diskalakan dan diputar 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 disediakan var(--icon-fill) dan var(--icon-fill-hover) untuk pengisinya, 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, meningkatkan skala 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: 1) {
        transform: translateX(0);
        cx: 17;
      }
    }
  }
}

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 stateful, tetapi tanpa transisi pada tahap ini. Bagian berikut membahas semuanya tentang menentukan transisi bagaimana dan apa.

Berbagi kueri media dan mengimpor easing

Untuk memudahkan penempatan transisi dan animasi di belakang preferensi gerakan sistem operasi pengguna, plugin PostCSS Custom Media memungkinkan penggunaan sintaksis spesifikasi CSS yang disusun 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 easing dari Props Terbuka:

@import "https://unpkg.com/open-props/easings.min.css";

/* usage example */
.sun {
  transition: transform .5s var(--ease-elastic-3);
}

Matahari

Transisi matahari akan lebih menyenangkan daripada bulan, mencapai efek ini dengan easing memantul. Sinar matahari akan memantul sedikit saat berotasi dan bagian tengah matahari akan memantul kecil seiring perubahan skalanya.

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 mengikuti preferensi gerakan pengguna.

Penentuan waktu dengan penundaan dan durasi sangat penting untuk membuat transisi ini bersih. Jika matahari muncul terlalu awal, misalnya, transisi tidak terasa diatur atau menyenangkan, transisi akan terasa kacau.

​​.sun-and-moon {
  @media (--motionOK) {
    & .moon > circle {
      transform: translateX(-7px);
      transition: transform .25s var(--ease-out-5);

      @supports (cx: 1) {
        transform: translateX(0);
        cx: 17;
        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 gerakan yang dikurangi

Di sebagian besar GUI Challenges, saya mencoba mempertahankan beberapa animasi, seperti opasitas silang yang memudar, untuk pengguna yang lebih menyukai 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 penyimpanan lokal.

Pengalaman pemuatan halaman

Perhatikan bahwa tidak ada flash warna yang terjadi saat pemuatan halaman. Jika pengguna dengan skema warna gelap menunjukkan bahwa mereka lebih memilih cahaya dengan komponen ini, maka halaman dimuat ulang, pada awalnya halaman akan gelap, lalu akan berkedip menjadi terang. Untuk mencegah hal ini, Anda harus menjalankan pemblokiran JavaScript dalam jumlah kecil dengan tujuan menetapkan atribut HTML data-theme sedini mungkin.

<script src="./theme-toggle.js"></script>

Untuk mencapai hal ini, tag <script> biasa dalam <head> dokumen 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 dengan hemat, atribut HTML dapat disetel sebelum CSS utama menggambar halaman, sehingga mencegah flash atau warna.

JavaScript akan memeriksa preferensi pengguna dalam penyimpanan lokal terlebih dahulu untuk memeriksa preferensi sistem jika tidak ada yang ditemukan dalam 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 oleh fungsi untuk memodifikasi 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 penguraian dokumen HTML. Browser belum mengetahui tentang tombol "#theme-toggle", karena tag <head> belum diurai sepenuhnya. Namun, browser memiliki document.firstElementChild , alias tag <html>. Fungsi ini mencoba menetapkan keduanya agar keduanya tetap sinkron, tetapi saat dijalankan pertama kali hanya dapat menetapkan tag HTML. Pada awalnya, querySelector tidak akan menemukan apa pun dan operator rantai opsional memastikan tidak ada error sintaksis jika tidak ditemukan dan fungsi setAttribute dicoba untuk dipanggil.

Selanjutnya, fungsi reflectPreference() tersebut segera dipanggil sehingga dokumen HTML memiliki kumpulan atribut data-theme:

reflectPreference()

Tombol masih memerlukan atribut, jadi tunggu peristiwa pemuatan halaman, lalu kueri akan aman untuk dibuat, 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, dalam memori JavaScript dan dalam dokumen. Nilai tema saat ini harus diperiksa dan keputusan yang 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

Hal unik untuk tombol tema ini adalah sinkronisasi dengan preferensi sistem saat berubah. Jika pengguna mengubah preferensi sistem 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 saat peralihan sistem terjadi.

Capai 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 pengalihan tema

Kesimpulan

Setelah Anda tahu cara saya melakukannya, bagaimana Anda‽ 🙂

Mari lakukan diversifikasi pendekatan dan pelajari semua cara untuk membangun di web. Buat demo, link tweet me, dan saya akan menambahkannya ke bagian remix komunitas di bawah.

Remix komunitas