How to have theme-adaptive comment sections in Quarto websites

Making the theme of Utterances change with the site theme
quarto
webdev
Author

Luke

Published

June 15, 2025

Quarto sites can very easily have comment sections thanks to some pretty creative uses of GitHub Issues.

If you wanted to add a comment section to a page on your Quarto website usually you’d use something like this in your YAML front-matter:

comments:
  utterances:
    repo: username/reponame
    theme: github-dark

The problem with this is that you are locked into one theme. So if someone is browsing your site in light mode they might have to stare at an uncomfortably dark comment section. Or alternatively, some basement dweller’s eyes are burnt to a crisp by your glaringly white comment section.

Utterances itself has an option where you can set the theme to adapt to the user’s system theme, but this option does not appear to be implemented in Quarto.

Given that Utterances clearly already has the capability to change theme, I figured it could not be all too difficult to work around the fact that this particular setting is not currently functional in Quarto.

Indeed, I managed to get it working fairly quickly with simple HTML includes. What made this tricky is that in my initial solution, the theme would be set on page load. This meant that you could still experience a mismatch if you switched the theme while on the current page. Resolving this took a bit of JavaScript to ensure that theme changes are detected at the right times:

<div id="utterances-container"></div>

<div id="utterances-container"></div>

<script>
class UtterancesThemeManager {
  constructor() {
    this.container = document.getElementById('utterances-container');
    this.loaded = false;
    this.init();
  }

  getTheme() {
    const checks = [
      () => document.body.classList.contains('quarto-dark'),
      () => document.documentElement.classList.contains('quarto-dark'),
      () => document.body.getAttribute('data-bs-theme') === 'dark',
      () => document.documentElement.getAttribute('data-bs-theme') === 'dark',
      () => document.body.classList.contains('dark'),
      () => document.documentElement.classList.contains('dark')
    ];

    return checks.some(check => {
      try { return check(); } catch { return false; }
    }) ? 'dark' : 'light';
  }

  async waitForPageReady() {
    return new Promise((resolve) => {
      const checkReady = () => {
        const conditions = [
          document.readyState === 'complete',
          document.body.offsetHeight > 0, 
          window.getComputedStyle(document.body).backgroundColor !== '', 
          !document.querySelector('link[rel="stylesheet"]:not([media="print"])') || 
          Array.from(document.querySelectorAll('link[rel="stylesheet"]:not([media="print"])')).every(link => link.sheet)
        ];

        if (conditions.every(Boolean)) {
          setTimeout(resolve, 200);
        } else {
          setTimeout(checkReady, 50);
        }
      };

      checkReady();
    });
  }

  async loadUtterances() {
    if (this.loaded) return;

    try {
      await this.waitForPageReady();

      let theme = this.getTheme();
      await new Promise(resolve => setTimeout(resolve, 100));

      const stableTheme = this.getTheme();
      if (theme !== stableTheme) {
        theme = stableTheme;
        await new Promise(resolve => setTimeout(resolve, 100));
      }

      console.log('Loading Utterances with theme:', theme);

      this.container.innerHTML = '';

      const script = document.createElement('script');
      script.src = 'https://utteranc.es/client.js';
      script.setAttribute('repo', 'lukmayer/site_comments');
      script.setAttribute('issue-term', 'pathname');
      script.setAttribute('theme', theme === 'dark' ? 'github-dark' : 'github-light');
      script.setAttribute('crossorigin', 'anonymous');
      script.setAttribute('async', '');

      script.onload = () => {
        this.loaded = true;
        console.log('Utterances successfully loaded');
      };

      this.container.appendChild(script);

    } catch (error) {
      console.error('Error loading Utterances:', error);
    }
  }

  updateTheme() {
    const iframe = document.querySelector('.utterances-frame');
    if (iframe) {
      const theme = this.getTheme();
      const utterancesTheme = theme === 'dark' ? 'github-dark' : 'github-light';

      try {
        iframe.contentWindow.postMessage(
          { type: 'set-theme', theme: utterancesTheme },
          'https://utteranc.es'
        );
        console.log('Updated Utterances theme to:', theme);
      } catch (e) {
        console.warn('Failed to update Utterances theme:', e);
      }
    }
  }

  setupObservers() {
    let updateTimeout;
    const debouncedUpdate = () => {
      clearTimeout(updateTimeout);
      updateTimeout = setTimeout(() => this.updateTheme(), 150);
    };

    const observerConfig = {
      attributes: true,
      attributeFilter: ['class', 'data-bs-theme']
    };

    [document.body, document.documentElement].forEach(element => {
      const observer = new MutationObserver(debouncedUpdate);
      observer.observe(element, observerConfig);
    });
  }

  init() {
    this.setupObservers();

    if (document.readyState === 'loading') {
      window.addEventListener('load', () => {
        setTimeout(() => this.loadUtterances(), 300);
      });
    } else {
      setTimeout(() => this.loadUtterances(), 500);
    }
  }
}

new UtterancesThemeManager();
</script>

Using this more complicated include requires the following setup for the page meant to include the comment section:

format:
  html:
    include-after-body: ../../_includes/utterances.html

Which means that the big chunk above should be saved to a file that’s accessed in the include-after-body setting. In case you don’t know, this uses relative paths, where ./ would be your project’s root directory and ../ simply means navigating up one directory from the source file location.

Hope this helps! If something is unclear, leave a comment in the theme-adaptive comment section below and I’ll try to clarify.