Halo!
šŸ‘‹

Mode Gelap di Web

UX, DX dan Eksplorasi Teknis untuk Mode Gelap yang Inklusif

  • Dipublikasikan padaĀ 
  • 10+ menitĀ waktu baca
Artikel ini juga tersedia dalam bahasa .

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.

Perbandingan peramban untuk meta tag color-scheme di mode gelap. Dari kiri ke kanan (atas ke bawah pada mobile): Chromium, Safari, Firefox. Tautan CodeSandbox

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:

Demonstrasi video Flash of Default Theme

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.

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 styled
const SidebarStyled = styled.aside`
color: ${props => props.theme.color.darkPrimary};
`;
// atau properti `css` maupun style inline
const SidebarCSS = () => {
const theme = useTheme();
return <aside css={{ color: theme.color.darkPrimary }} />;
};

Kita tulis kodenya menjadi seperti ini:

// styled API
const SidebarStyled = styled.aside`
color: var(--color-dark-primary);
`;
// atau properti `css` maupun style inline
const 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 styled
const SidebarStyled = styled.aside`
color: ${theme.color.darkPrimary};
`;
// atau properti `css` maupun style inline
const 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:

  1. 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" />;
};
  1. Kurangi kecerahan dan tingkatkan kontras untuk tipe media lain
Kiri (atas di mobile): Gambar tanpa filter CSS. Kanan (bawah di mobile): Gambar dengan penerapan filter kecerahan dan kontras

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);
}
}
  1. Kalian dapat membalik warna menggunakan filter CSS untuk beberapa gambar.
Compiling
XKCD Comic 303: Compiling

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.

Bagaimana cara menambahkan dukungan mode gelap pada GitHub Gist

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.


  • 1
    Setidaknya untuk menambahkan dukungan awal yang hanya membaca preferensi sistem. Seiring kalian membaca, kalian akan mengerti ini memerlukan upaya lebih dari sekedar satu baris kode.ā†©
  • 2
    Kecuali 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.ā†©
  • 3
    Dengan asumsi kalian menggunakan oEmbed API.ā†©
  • 4
    Semoga segera, mengingat aplikasi mobile mereka sudah mendukungnya.ā†©

Dikategorikan dalam

Webmentions

Jika kalian pikir artikel ini berguna