Đồng bộ theme Giscus với dark mode của site

Đăng ngày

Vấn đề

Trước đó, Widget comment trên blog này không theo theme của site.

Dự án đang dùng Giscus với preferred_color_scheme lúc khởi tạo, nên theo thiết bị chứ không theo nút sun/moon tùy chỉnh. Widget còn nằm trong iframe lazy-load, nên theme update gửi quá sớm sẽ mất luôn.

Giải pháp

Ý tưởng cốt lõi rất đơn giản: một ThemeSwitcher lo cả theme của trang lẫn cho Giscus.

Component đó sẽ:

  • Restore class html.dark từ localStorage, fallback về preference của OS.
  • Toggle class đó khi click, persist lại, rồi dispatch event themeChanged.
  • Map html.dark sang theme Giscus rồi apply khi iframe sẵn sàng, và apply lại mỗi lần themeChanged.

Implementation

Lưu ý

  • Các snippet bên dưới chỉ giữ phần liên quan đến theming.
  • Các implementation sẽ được viết đơn giản nhất có thể, nên một số chỗ có thể chưa theo best practices!

Use Case

Header đã có nút sun/moon. Bài viết render iframe Giscus ở cuối trang. Cả hai nên theo cùng class dark trên <html>.

Restore theme của site

Lúc load, ThemeSwitcher quyết định theme, rồi ghi lên document.documentElement.

// src/layouts/components/ThemeSwitcher.astro
const theme = (() => {
	// 1. Check localStorage TRƯỚC
	if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
		return localStorage.getItem('theme');
	}
	// 2. Check preference của OS SAU
	if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
		return 'dark';
	}
	// 3. Default về 'light'
	return 'light';
})();

if (theme === 'light') {
	document.documentElement.classList.remove('dark');
} else {
	document.documentElement.classList.add('dark');
}

window.localStorage.setItem('theme', theme || '');

Tới đây, phần còn lại của trang đã đúng mode. Giscus thì chưa, vì nó không thấy class đó.

Broadcast các thay đổi sau đó

Toggle vẫn làm hai việc: toggle class dark và persist lựa chọn của người dùng. Sau đó trigger themeChanged.

// src/layouts/components/ThemeSwitcher.astro
const handleToggleClick = () => {
	const element = document.documentElement;
	element.classList.toggle('dark');

	const isDark = element.classList.contains('dark');
	localStorage.setItem('theme', isDark ? 'dark' : 'light');
	window.dispatchEvent(new CustomEvent('themeChanged', { detail: { theme: isDark ? 'dark' : 'light' } }));
};

document.getElementById('themeToggle')?.addEventListener('click', handleToggleClick);

Đồng bộ theme cho Giscus

Cùng component đó map html.dark sang light hoặc dark_dimmed, rồi apply thành data-theme trên thẻ script và gửi postMessage vào iframe của Giscus.

// src/layouts/components/ThemeSwitcher.astro
const GISCUS_ORIGIN = 'https://giscus.app';
const lightTheme = 'light';
const darkTheme = 'dark_dimmed';

function currentGiscusTheme() {
	return document.documentElement.classList.contains('dark') ? darkTheme : lightTheme;
}

function applyGiscusTheme() {
	const nextTheme = currentGiscusTheme();
	document.querySelector('script[src="https://giscus.app/client.js"]')?.setAttribute('data-theme', nextTheme);
	document.querySelector('iframe.giscus-frame')?.contentWindow?.postMessage({ giscus: { setConfig: { theme: nextTheme } } }, GISCUS_ORIGIN);
}

Cho phép applyGiscusTheme() chạy ngay, nhưng iframe có thể chưa có. Giscus emit resizeHeight khi đã sẵn sàng; đó là lần apply đầu đáng tin. Các lần toggle sau đi qua themeChanged.

function onGiscusMessage(event) {
	if (event.origin !== GISCUS_ORIGIN) return;
	if (!(typeof event.data === 'object' && event.data && event.data.giscus)) return;
	if (!('resizeHeight' in event.data.giscus)) return;
	applyGiscusTheme();
	window.removeEventListener('message', onGiscusMessage);
}

applyGiscusTheme();
window.addEventListener('message', onGiscusMessage);
window.addEventListener('themeChanged', applyGiscusTheme);

Kết luận

ThemeSwitcher chính là toàn bộ fix: restore html.dark, trigger themeChanged event, và update iframe khi Giscus báo đã sẵn sàng. Phần theme của Giscus sẽ theo trang, không cần reload.