Đồng bộ theme Giscus với dark mode của site
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.darktừ localStorage, fallback về preference của OS. - Toggle class đó khi click, persist lại, rồi dispatch event themeChanged.
- Map
html.darksang 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.