Membuat Dark Mode dengan CSS dan localStorage
Membuat tema terang, gelap, dan otomatis mengikuti sistem sambil menjaga teks, form, fokus, serta komponen tetap terbaca.
Dark mode bukan sekadar mengganti latar putih menjadi hitam. Implementasi yang matang perlu mengatur token warna, menentukan prioritas preferensi, mencegah kilatan tema saat halaman dimuat, dan memastikan semua komponen tetap memiliki kontras yang cukup.
- Otomatis: mengikuti preferensi sistem.
- Terang: pilihan eksplisit pengguna.
- Gelap: pilihan eksplisit pengguna.
- Pilihan eksplisit disimpan, sedangkan mode otomatis tetap mengikuti perubahan sistem.
Bangun warna dari token semantik
Hindari menulis warna literal pada puluhan selector. Nama token sebaiknya menjelaskan fungsi, seperti latar halaman, permukaan kartu, teks utama, teks sekunder, garis, dan aksen.
:root {
color-scheme: light;
--bg: #f7f8ff;
--surface: #ffffff;
--text: #1d2435;
--muted: #5e687d;
--line: #d8deec;
--accent: #5b4df5;
--on-accent: #ffffff;
}
:root[data-theme="dark"] {
color-scheme: dark;
--bg: #0c1020;
--surface: #151a2d;
--text: #f2f4ff;
--muted: #b5bdd1;
--line: #303852;
--accent: #9b8cff;
--on-accent: #121528;
}
Properti color-scheme memberi tahu browser skema yang digunakan sehingga kontrol native, scrollbar, dan warna form dapat menyesuaikan. Tetap uji setiap input karena hasil native dapat berbeda antarbrowser.
Tentukan urutan prioritas
Aturannya sederhana: pilihan tersimpan menang atas preferensi sistem. Jika tidak ada pilihan tersimpan, baca prefers-color-scheme. Simpan hanya light atau dark; ketiadaan key berarti otomatis.
const STORAGE_KEY = "ngodingsantai-theme";
const systemTheme = window.matchMedia("(prefers-color-scheme: dark)");
function readPreference() {
try {
const value = localStorage.getItem(STORAGE_KEY);
return value === "light" || value === "dark" ? value : null;
} catch {
return null;
}
}
function resolveTheme() {
return readPreference() ?? (systemTheme.matches ? "dark" : "light");
}
Akses storage dapat gagal pada kondisi privasi atau kebijakan browser tertentu. try/catch membuat tema tetap bekerja dengan preferensi sistem meskipun penyimpanan tidak tersedia.
Cegah kilatan tema saat halaman dimuat
Jika script tema menunggu sampai akhir body, browser sempat menggambar tema default. Jalankan script kecil di head sebelum stylesheet utama atau buat file tema yang sangat kecil dan dimuat sinkron.
<script>
(() => {
const key = "ngodingsantai-theme";
let saved = null;
try { saved = localStorage.getItem(key); } catch {}
const systemDark = matchMedia("(prefers-color-scheme: dark)").matches;
const theme = saved === "light" || saved === "dark"
? saved
: systemDark ? "dark" : "light";
document.documentElement.dataset.theme = theme;
})();
</script>
Script ini sengaja pendek dan tidak menunggu DOMContentLoaded karena elemen html sudah tersedia. Jangan melakukan pekerjaan lain di jalur ini agar rendering awal tetap cepat.
Buat tombol yang menjelaskan aksi berikutnya
<button type="button" data-theme-toggle
aria-label="Gunakan tema gelap">
<svg aria-hidden="true">...</svg>
</button>
const toggle = document.querySelector("[data-theme-toggle]");
function applyTheme(theme) {
document.documentElement.dataset.theme = theme;
toggle?.setAttribute(
"aria-label",
theme === "dark" ? "Gunakan tema terang" : "Gunakan tema gelap"
);
}
toggle?.addEventListener("click", () => {
const next = resolveTheme() === "dark" ? "light" : "dark";
try { localStorage.setItem(STORAGE_KEY, next); } catch {}
applyTheme(next);
});
Label tombol menyebut aksi yang akan terjadi, bukan hanya keadaan saat ini. Ikon tetap dekoratif karena nama kontrol sudah diberikan melalui aria-label.
Sediakan cara kembali ke mode otomatis
Dua tombol saja sering membuat pengguna tidak bisa kembali mengikuti sistem. Gunakan tiga pilihan dalam kelompok radio atau tambahkan aksi “Ikuti sistem”.
function useSystemTheme() {
try { localStorage.removeItem(STORAGE_KEY); } catch {}
applyTheme(systemTheme.matches ? "dark" : "light");
}
systemTheme.addEventListener("change", (event) => {
if (readPreference() === null) {
applyTheme(event.matches ? "dark" : "light");
}
});
Listener perubahan sistem hanya boleh mengubah halaman jika pengguna berada pada mode otomatis. Pilihan eksplisit tidak boleh tiba-tiba diganti ketika sistem berubah.
Jangan hanya memeriksa teks utama
Audit seluruh keadaan komponen: hover, focus, disabled, placeholder, badge, callout, tombol primer, tombol ghost, garis pemisah, kode, dan pesan error. Warna aksen yang terlihat bagus di latar gelap belum tentu memberi kontras cukup ketika dipakai sebagai latar tombol.
| Komponen | Yang diperiksa |
|---|---|
| Teks utama | Kontras terhadap latar halaman dan kartu |
| Tombol primer | Teks terhadap aksen pada kedua tema |
| Input | Label, value, placeholder, border, focus |
| Link | Dapat dibedakan tanpa warna saja |
| Grafik/gambar | Tidak hilang pada permukaan gelap |
Kurangi animasi ketika pengguna memintanya
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
Preferensi warna dan preferensi gerakan adalah kebutuhan berbeda. Tema gelap tidak otomatis berarti animasi harus dimatikan, dan reduced motion tidak menentukan warna.
Skenario pengujian
- Hapus key storage, atur sistem terang, lalu muat ulang.
- Hapus key storage, atur sistem gelap, lalu muat ulang.
- Pilih tema eksplisit dan pastikan bertahan setelah refresh.
- Kembali ke otomatis dan ubah tema sistem saat halaman terbuka.
- Blokir storage melalui mode privasi atau mock error; halaman harus tetap dapat digunakan.
- Uji sebelum konten tampil untuk melihat apakah terjadi flash.
- Periksa semua tombol dan input dengan keyboard pada kedua tema.