Đồ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.
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.
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.
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.