Untuk beberapa orang, mode gelap terlihat lebih indah. Di lain hal, ada beberapa orang (termasuk saya) yang suka membaca di malam hari tanpa penerangan yang memadai, atau bahkan di dalam kegelapan total. Adanya opsi untuk menggunakan mode gelap mengurangi beban di mata dan membuat tulisan lebih nyaman dibaca. Saya sendiri tidak terlalu peduli dengan aspek estetik dari mode gelap. Saya menganggap itu sebagai fitur aksesibilitas.
Memang, membaca tulisan putih di latar belakang gelap itu kurang nyaman dibandingkan membaca tulisan hitam di latar belakang terang. Tapi kalian tahu apa yang lebih buruk lagi? Saat kita membuka situs di malam hari dan tiba-tiba disilaukan oleh cahaya latar belakang yang sangat terang.
Saya sudah mengunjungi beberapa situs tersebut, saya akhirnya menutup dan berhenti membaca. Saya tidak yakin saya mengunjungi kembali pagi harinya. Ini alasan mengapa saya berusaha sebaik mungkin untuk memastikan pengalaman mode gelap di situs saya sendiri menyenangkan.
Note: Saya tidak bekerja di bidang UX dan tidak memiliki latar belakang UX. Semua petunjuk dan saran di sini murni berasal dari pendapat pribadi. Jika kalian merasa ada sesuatu yang perlu saya tahu silahkan kontak melalui Twitter.
Preferensi Tema
Semua peramban mayoritas sudah mendukung mode gelap (sampai tingkat tertentu). Sekarang sangat mudah untuk menambahkan dukungan mode gelap di situs web. Prosesnya sendiri cukup mudah1. Kalian tambahkan meta tag yang memberi tahu peramban bahwa situs kalian dapat digunakan baik di mode terang maupun gelap.
<meta name="color-scheme" content="light dark" />
Dengan menggunakan meta tag ini, semua style bawaan yang dimiliki oleh peramban akan disesuaikan dengan preferensi dari sistem. Jika kalian lebih memilih mode gelap sebagai default, kalian dapart mengganti nilainya menjadi dark light
.
Berdasarkan observasi saya, Safari memiliki implementasi terbaik karena dia juga menangani form input (text field, radio, dll), tidak seperti peramban berbasis Chromium. Saya juga lebih suka pilihan warna di Safari. Implementasi Firefox terburuk karena tidak melakukan apapun.
Sebagai tambahan dari meta tag, kalian dapat menambahkan media query prefers-color-scheme untuk memilih warna sendiri.
body { color: #000; background-color: #fff;}@media (prefers-color-scheme: dark) { body { color: #fff; background-color: #000; }}
Ketika menggunakan JavaScript, kalian dapat menggunakan fungsi window.matchMedia
untuk mendeteksi dukungan peramban:
const media = window .matchMedia ('prefers-color-scheme');if (media .media !== 'not all') { // tidak didukung}
Kalian harus selalu menghormati preferensi sistem pengguna kecuali mereka secara eksplisit memilih suatu tema. Satu-satunya kasus di mana kalian bisa memaksa pilihan tema awal menjadi terang atau gelap, adalah ketika mode gelap tidak didukung oleh peramban.
Jika memang tidak didukung, kalian dapat menggunakan tema apapun yang kalian inginkan. Kalian bisa menjadi kreatif dan menyetel berdasarkan waktu pengguna, misalkan berdasarkan matahari terbit/tenggelam. Namun, jangan sampai mengabaikan preferensi sistem pengguna. Sistem itu ada karena suatu alasan.
Tema default harus selalu merujuk pada tema sistem, jika tersedia.
Preferensi Pengguna
Menghormati preferensi sistem sudah merupakan langkah awal yang baik. Alangkah lebih baiknya lagi jika kalian memberikan pengguna kontrol untuk mengubah tema mereka sendiri. Ini dapat diimplementasikan menggunakan toggle, switch, select, atau elemen desain apapun tergantung pilihan yang kalian berikan.
Saya menggunakan select untuk memungkinkan pengguna untuk mengatur ulang tema mereka kembali ke preferensi sistem (ketika mereka mau), sebagai tambahan dari opsi gelap & terang. Metode ini bekerja dengan cukup baik di beberapa perangkat seperti desktop, tablet, dan telepon genggam. Saya juga tidak harus memikirkan antarmuka untuk menampilkan pilihan.
Mengingat sekarang kebanyakan sistem operasi sudah memiliki pengaturan mode gelap otomatis, adanya pilihan untuk mengatur ulang preferensi kembali ke sistem memungkinkan pengguna untuk mencoba tema yang berbeda dan mengatur ulang kembali setelahnya.
Ini bukanlah sebuah persyaratan dalam mendukung mode gelap, melainkan tentang memberikan pilihan kepada pengguna.
Flash of Default Theme (FODT)
Jika kalian hanya menyediakan toggle mode gelap, pengguna mungkin akan mengalami apa yang saya sebut Flash of Default Theme (singkatnya FODT). Ini umumnya terjadi ketika pilihan tema pengguna berselisih dengan preferensi sistem mereka. Misalnya: preferensi sistemnya gelap sedangkan mereka memilih mode terang, dan sebaliknya.
Terlihat seperti ini:
Yang sebenarnya terjadi adalah kode yang mendeteksi preferensi pengguna, dan mengubah tema saat runtime itu dieksekusi setelah peramban selesai me-render. Hal ini menyebabkan peramban untuk mem-paint ulang halaman sebanyak dua kali. Pertama, sebelum kode dieksekusi, dan yang kedua setelahnya. Ini dapat terjadi ketika kalian menulis kode untuk pemilihan preferensi di dalam kerangka kerja antarmuka, yang umumnya dibundel dan disajikan lewat script tag menggunakan atribut async
/defer
.
Kita sudah cukup terbiasa menempatkan kode JavaScript sebelum elemen penutup body dengan atribut async
/defer
, kita lupa bahwa memiliki kode yang blocking itu bukan selamanya hal yang buruk. Dengan kode blocking, peramban dapat berhenti me-render halaman sampai kode kita selesai dieksekusi. Dengan memanfaatkan tingkah laku ini, kita dapat mengatur nilai warna runtime sesuai preferensi pengguna, sebelum rendering terjadi.
<html> <head> <script type="text/javascript"> try { const preference = localStorage.getItem('theme-pref'); if (preference) { document.documentElement.setAttribute('data-theme', preference) } } catch (err) { // tidak lakukan apapun } </script> </head> <body> <div id="app"></div> <script defer src="/chunk.js"></script></html>
Di sini kita menambahkan atribut data-theme
pada elemen html
dan kita biarkan CSS mengambil alih sisanya. Kita akan lihat nanti mengapa kita menggunakan atribut data untuk menandai preferensi pengguna.
Ketika menggunakan React (seperti saya), ini artinya kalian harus menggunakan dangerouslySetInnerHTML
untuk melampirkan kodenya.
Kekurangan ketika kita inline kode menggunakan metode itu adalah hilangnya syntax highlighting ataupun integrasi analisis statis (misal lint) di editor teks. Sebagai alternatif, kalian bisa menyimpan kode di file lain dan mengimpornya secara inline saat waktu build.
Pro tip: Gunakan raw-loader
jika kalian memakai webpack, atau raw.macro
yang memanfaatkan babel-plugin-macros
untuk mendapatkan isi dari file saat waktu build.
Karena kita mengimpor file sebagai string mentah, kita hanya boleh menggunakan sintaks JavaScript yang valid dan bisa digunakan di semua browsers. Artinya, tidak ada TypeScript, import
ataupun require
.
import React from 'react';import { Main, NextScript } from 'next/document';import raw from 'raw.macro';export default function Document() { return ( <html> <head> <script dangerouslySetInnerHTML={{ __html: raw('./antiFODT.js') }} /> <link rel="stylesheet" type="text/css" href="/style.css" /> </head> <body> <Main /> <NextScript /> </body> </html> );}
Dengan cara ini, peralatan JavaScript seperti linter dan pemformat kode tetap dapat digunakan.
CSS Variables lawan React Context
Jika kalian menggunakan React dan membutuhkan cara untuk menambah mode gelap di komponen, hal pertama yang terpikir di benak kalian mungkin dengan menggunakan React Context. Tentu saja, itu adalah mekanisme yang baik untuk membagikan nilai ke komponen jauh di dalam tree. Sayangnya, dengan menggunakan React Context atau solusi tema biasa yang hanya menggunakan JS kurang baik untuk performa (dan selanjutnya UX), karena kita harus menunggu rendering komponen atau kita akan mendapatkan FOTD.
Mendukung preferensi pengguna, menggunakan React context, dan SSR tidaklah kompatibel. Dengan melakukan server-side rendering artinya kalian harus tahu dari awal apa preferensi pengguna tersebut, yang artinya tidak mungkin2.
Saya cukup senang karena implementasi mode gelap hanya menggunakan satu hooks dan tanpa React Context.
Ini caranya:
Pertama kali perlu dicatat bahwa semua referensi warna di JavaScript dapat diganti dengan CSS Variables, bahkan ketika menggunakan CSS-in-JS. Daripada menggunakan tema dari context melalui JavaScript, kita merujuk nilainya langsung dengan menggunakan CSS Variables.
Artinya, alih-alih kita menulis kode seperti ini:
// API styledconst SidebarStyled = styled .aside ` color: ${props => props .theme .color .darkPrimary };`;// atau properti `css` maupun style inlineconst SidebarCSS = () => { const theme = useTheme (); return <aside css ={{ color : theme .color .darkPrimary }} />;};
Kita tulis kodenya menjadi seperti ini:
// styled APIconst SidebarStyled = styled .aside ` color: var(--color-dark-primary);`;// atau properti `css` maupun style inlineconst SidebarCSS = () => { return <aside css ={{ color : 'var(--color-dark-primary)' }} />;};
Dengan mengubah deklarasi style dari nilai dinamis berdasarkan context ke statis, kita tidak menyia-nyiakan sumber daya untuk me-render ulang komponen hanya karena tema berubah.
Metode ini bekerja cukup baik, walaupun masih ada kekurangannya.
Strongly-typed CSS Variables
Ketika kalian sudah terbiasa dengan TypeScript, kalian mungkin pikir solusi kedua lebih buruk. Kita tidak bisa mengecek nilainya pada waktu compile. Tidak akan ada pesan eror dan tampilan bisa tiba-tiba rusak. CSS Variables tidak menjamin penggunaan nilai yang tepat. Sangat mudah untuk merujuk nilai yang salah secara tidak sengaja karena typo (apalagi ketika kalian menggunakan butterfly keyboard 😉).
Untuk memecahkan masalah ini, saya membuat modul kecil untuk menulis CSS Variables secara type safe menggunakan TypeScript yang saya sebut theme-in-css
. Dengan modul ini, saya dapat mendefinisikan semua nilai tema di TypeScript dan menggunakannya sebagai CSS Variables.
import { createTheme } from 'theme-in-css';export const theme = createTheme ({ color : { darkPrimary : '#000', },});
Contoh di atas kemudian menjadi:
import styled from 'styled-components';import { theme } from './theme';// API styledconst SidebarStyled = styled .aside ` color: ${theme .color .darkPrimary };`;// atau properti `css` maupun style inlineconst SidebarCSS = () => { return <aside css ={{ color : theme .color .darkPrimary }} />;};
Sekarang kita mendapat hal terbaik dari kedua metode.
Komponen yang menggunakan nilai tema tidak perlu me-render ulang hanya karena tema berubah. Hanya sebagian kecil komponen yang benar-benar bergantung pada preferensi tema (seperti toggle) yang perlu.
Langkah terakhir adalah dengan menambahkan semua CSS Variables pada style global.
import { createGlobalStyle } from 'styled-components';import { theme , darkTheme } from './theme';const GlobalStyle = createGlobalStyle ` :root { ${theme .css .string } } [data-theme="dark"] { ${darkTheme .css .string } } @media (prefers-color-scheme: dark) { :root:not([data-theme]) { ${darkTheme .css .string } } }`;
Pertama kita putuskan tema default (pada kasus ini terang) dan mengatur semua properti pada selector :root
. Kemudian, kita tambahkan penimpa style menggunakan atribut data-theme
untuk mode gelap dari preferensi pengguna. Akhirnya kita tambahkan media query untuk menyesuaikan mode gelap dari preferensi sistem, tapi hanya jika pengguna belum memilih sebuah tema.
Masih ingat data-theme
di atas? Ini alasan mengapa kita menyetelnya di dalam blocking JS. Ketika peramban mulai me-render halaman dan aturan style sudah dihitung, dia akan menggunakan nilai yang benar.
Hooks tanpa Context
Menggunakan CSS saja sudah mencakup banyak kasus penggunaan untuk styling, tapi kita juga butuh mekanisme lain untuk membagikan nilai tema saat ini di dalam JS. Secara teknis, tidak masalah jika menggunakan React Context, tapi ada juga cara lain tanpa menggunakan Context dan tetap bisa berbagi nilai lintas komponen di dalam tree.
Hal pertama yang saya lakukan adalah mencatat semua nilai yang perlu dibagikan. Saya temukan 3 hal: tema saat ini (gelap / terang), preferensi pengguna (gelap / terang / sistem), dan sebuah fungsi untuk mengganti preferensi.
Tema saat ini secara teknis adalah nilai yang dihitung berdasarkan preferensi pengguna. Saking seringnya digunakan, saya putuskan untuk memindahkannya ke nilai lain dan menempatkannya pada elemen pertama di tuple.
Ini API lengkapnya:
const [theme , preference , setPreference ] = useDarkMode ();
Umumnya, saya hanya membutuhkan nilai theme
.
function Component () { const [theme ] = useDarkMode (); if (theme === 'dark') { return <div />; } return <div />;}
Pertanyaan selanjutnya adalah, bagaimana membagikan nilai tema antar komponen tanpa context? Jawabannya adalah persisted state. Kalian juga bisa menggunakan solusi lain seperti Recoil untuk sinkronisasi state tanpa context dan menambahkan logika sendiri untuk memperbarui nilai di localStorage.
import createPersistedState from 'use-persisted-state';const useUIPreference = createPersistedState ('paper/ui-pref');function useDarkMode (): [Theme , Preference , SetPreference ] { const [preference , setPreference ] = useUIPreference <Preference >(null); const theme : Theme = 'dark'; // ?? return [theme , preference , setPreference ];}
Untuk menghitung nilai tema saat ini, kita bandingkan preferensi pilihan pengguna dan preferensi sistem:
import useMedia from './useMedia';import createPersistedState from 'use-persisted-state';const useUIPreference = createPersistedState ('paper/ui-pref');function useDarkMode (): [Theme , Preference , SetPreference ] { const nativeDarkMode = useMedia ('(prefers-color-scheme: dark)', false); const [preference , setPreference ] = useUIPreference <Preference >(null); // mode terang dari awal let theme : Theme = 'light'; if ( // tidak ada preferensi pengguna dan mode gelap tersedia (preference === null && nativeDarkMode ) || // pengguna memilih mode gelap secara eksplisit preference === 'dark' ) { theme = 'dark'; } return [theme , preference , setPreference ];}
Di sini saya menggunakan hooks useMedia
yang mengembalikan nilai di mana media query sesuai dengan state peramban saat ini. Saya juga menggunakan hooks ini untuk mendeteksi dukungan hover di peramban untuk menentukan apakah saya harus menampilkan error di contoh kode inline atau saat hover.
Integrasi CSS
Terakhir kita buat supaya jika preferensi pengguna berubah, CSS Custom Property juga berubah. Ini dapat dilakukan dengan membuat sebuah effect yang menanggapi perubahan dari nilai preference
. Kita tidak harus memperbarui semua CSS Custom Property satu demi satu. Karena kita sudah menggunakan atribut data-theme
, kita dapat memanfaatkan logika cascade CSS.
Ini semudah manipulasi atribut DOM.
import React from 'react';import createPersistedState from 'use-persisted-state';const useUIPreference = createPersistedState ('paper/ui-pref');function useDarkMode () { const [preference , setPreference ] = useUIPreference <Preference >(null); React .useEffect (() => { const root = document .documentElement ; if (preference === null) { root .removeAttribute ('data-theme'); } else { root .setAttribute ('data-theme', preference ); } }, [preference ]); return [theme , preference , setPreference ];}
Media Lain, Konten Dinamis, dan Embed Pihak Ketiga
Latar belakang dan warna teks adalah perubahan termudah ketika menambah dukungan mode gelap. Gambar dan konten dinamis lain seperti GIF dan video lebih rumit. Menurut pendapat saya ini adalah pedoman yang kalian bisa ikuti:
- Jika kalian membuat ilustrasi sendiri, coba siapkan versi gelap.
Habiskan waktu untuk membuat 2 gambar berbeda untuk mode terang dan gelap. Kalian bisa gunakan elemen picture
dan media query untuk menyajikan gambar yang berbeda.
<picture> <source srcset="/static/image-dark.jpg" media="(prefers-color-scheme: dark)" /> <img src="/static/image-light.jpg" /></picture>
Ya, ini hanya berguna untuk pengaturan sistem. Jika kalian juga ingin mendukung preferensi pengguna, kalian harus menambahkan logika kondisi saat render.
const Image = () => { const [theme ] = useDarkMode (); if (theme === 'dark') { return <img src ="/static/image-dark.jpg" />; } return <img src ="/static/image-light.jpg" />;};
- Kurangi kecerahan dan tingkatkan kontras untuk tipe media lain
Ketika kalian tidak memiliki aset mode gelap, kalian dapat menggunakan kombinasi filter brightness
dan contrast
untuk membantu gambar lebih menyatu dengan halaman.
[data-theme='dark'] img { filter: brightness(0.8) contrast(1.2);}@media (prefers-color-scheme: dark) { :root:not([data-theme]) img { filter: brightness(0.8) contrast(1.2); }}
- Kalian dapat membalik warna menggunakan filter CSS untuk beberapa gambar.
Ini tidak akan diterapkan secara benar ke semua gambar, jadi pastikan kalian telah melakukan pengujian sebelumnya. Salah satu kandidat terbaik adalah gambar hitam putih (seperti XKCD).
@media (prefers-color-scheme: dark) { img[data-dark-ready] { filter: invert(100%); }}
Kalian bisa juga bermain-main dengan hue-rotate
untuk mengimbangi perubahan hue.
Penyorotan Sintaks
Seperti dijelaskan di artikel sebelumnya, saya menggunakan Shiki untuk menampilkan HTML yang telah disorotkan saat waktu build daripada saat runtime menggunakan client-side rendering. Selain lebih cepat dimuat, menggunakan CSS Variables berarti contoh kode tetap ditampilkan secara benar walaupun JavaScript dimatikan ataupun kasus lain dimana dia gagal atau lambat. Ini adalah perbaikan nyata dibandingkan solusi sebelumnya menggunakan impor dinamis.
Saya menggunakan theme-in-css
juga untuk mengelola tema untuk penyorotan sintaks, tapi alih-alih menggunakan properti color
, saya menggunakan properti syntax
.
import lightSyntax from '@pveyes/aperture/themes/pallete-light.json';import darkSyntax from '@pveyes/aperture/themes/pallete-dark.json';export const theme = createTheme({ syntax: lightSyntax,});export const darkTheme = createTheme({ syntax: darkSyntax,});
Twitter Card
Kalian dapat menggunakan meta tag untuk membuat tweet ditampilkan dalam mode gelap3. Setelah kalian menerima respon HTML dari API, tambahkan dengan meta tag twitter:widgets:theme
.
async function getTwitterEmbedHTML (url : string, theme : Theme ) { const res = await fetch ( `https://publish.twitter.com/oembed?url=${url }&hide_thread=true` ); const json = await res .json (); let html = ''; html += `<meta name="twitter:widgets:theme" content="${theme }" />`; html += json .html ; return html ;}
Ketiak menggunakan oEmbed di dalam iframe, kalian mungkin melihat kedipan dan lompatan tampilan pada muatan awal. Lebih parahnya lagi, ketika beralih antara terang dan gelap, hal itu terjadi lagi. Ini karena HTML untuk Twitter Card ditampilkan client-side menggunakan web components, dan style-nya diaplikasikan ulang saat reload (karena di dalam iframe).
Sebagai alternatif, kalian dapat membuat renderer kalian sendiri. Ini yang saya lakukan dengan bantuan proyek tweet statis. Implementasinya lebih kompleks, tetapi hasilnya jauh lebih baik.
GitHub Gist
Menggunakan invert
dan hue-rotate
juga bekerja cukup baik untuk GitHub Gist. Sampai mereka memiliki dukugan mode gelap yang layak4, solusi ini dapat menjadi fallback yang dapat diterima.
Saya menyajikan Gist yang di-embed menggunakan iframe
dengan memanfaatkan fitur Vercel Edge Caching, jadi ini hanya sebatas penambahan style ke elemennya.
Daftar yang cukup panjang. Apakah kalian membutuhkan semuanya atau tidak tergantung pada tipe konten dan target audiens kalian. Aplikasi web biasanya hanya perlu memikirkan tentang latar belakang, teks, dan elemen-elemen input, sedangkan situs dengan konten-berat harus mempertimbangkan media lain juga.
- 1Setidaknya untuk menambahkan dukungan awal yang hanya membaca preferensi sistem. Seiring kalian membaca, kalian akan mengerti ini memerlukan upaya lebih dari sekedar satu baris kode.↩
- 2Kecuali kalian menyimpan preferensi pengguna di cookie, membacanya di server dan menyebarkannya melalui React Context. Ini tidak hanya mempersulit, tetapi juga membuat HTML tidak dapat di-cache yang buruk untuk performa.↩
- 3Dengan asumsi kalian menggunakan oEmbed API.↩
- 4Semoga segera, mengingat aplikasi mobile mereka sudah mendukungnya.↩