Fixing Giscus Theme Sync with the Site Dark Mode Toggle
Problem Statement
In the past, the Comments widget on this blog did not follow the site theme.
The project is using Giscus and boots with preferred_color_scheme, which tracks the device, not the custom sun/moon toggle. The widget also lives in a lazy iframe, so a theme update sent too early simply disappears.
Solutions
The core idea is simple: one ThemeSwitcher owns both the page theme and the Giscus.
That component will:
- Restore
html.darkfrom localStorage, with the OS preference as a fallback. - Flip that class on click, persist it, and dispatch a themeChanged event.
- Map
html.darkto a Giscus theme and apply it once the iframe is ready, then again on every themeChanged.
Implementation
Disclaimer
- The snippets below are reduced to the theming path.
- The implementations will be written in the simplest way, so some places might not follow best practices!
Use Case
A blog header already has a sun/moon button. Posts render a Giscus iframe at the bottom. Both should follow the same dark class on <html>.
Restoring the Site Theme
On load, ThemeSwitcher decides the theme once, then writes it onto document.documentElement.
// src/layouts/components/ThemeSwitcher.astro
const theme = (() => {
// 1. Check localStorage FIRST
if (typeof localStorage !== 'undefined' && localStorage.getItem('theme')) {
return localStorage.getItem('theme');
}
// 2. Check OS preference SECOND
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
return 'dark';
}
// 3. Default to 'light' LAST
return 'light';
})();
if (theme === 'light') {
document.documentElement.classList.remove('dark');
} else {
document.documentElement.classList.add('dark');
}
window.localStorage.setItem('theme', theme || '');
At this point the rest of the page is already in the right mode. Giscus still is not, because it never sees that class.
Broadcasting Later Changes
The toggle keeps doing two jobs: flip dark, and persist the choice. Then trigger themeChanged event.
// 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);
Sync theme for Giscus
The same component maps html.dark to light or dark_dimmed, then applies it as data-theme on the script tag and as a postMessage into the Giscus iframe.
// 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);
}
applyGiscusTheme() runs immediately, but the iframe may not exist yet. Giscus emits resizeHeight once it is up; that is the reliable first apply. Later toggles go through 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);
Conclusion
ThemeSwitcher is the whole fix: restore html.dark, trigger themeChanged event, and update the iframe once Giscus reports that it is ready. The Giscus theme will follow the page without reloading.