Quarto Plots Theme Switcher

Writing a Javascript file to have light/dark themed plots depending on your Quarto document theme.
R
Tutorial
Quarto
Javascript
Published

August 14, 2024

Introduction

While customizing my website’s style, I thought about having light and dark themes for both my posts and the plots included in them, eventually stumbling by Mickaël Canouil’s post about having this option for ggplot2 plots.

However, I also had plotly plots and gt tables which required different treatment in the script as plotly produced SVG files and gt tables were simply tables in HTML but much more complex due to their special structure.

Code

As mentioned in Mickaël Canouil’s post, we have to use knitr as the engine to generate the images; the only difference is that I created an alternative knitr handler for the light theme identical to the dark one.

Steps to follow :

  1. Include this code in the YAML header of your Quarto document. (Don’t forget to enable light and dark themes for your page)
knitr: 
  opts_chunk: 
    dev: [lightsvglite, darksvglite]
    fig.ext: [.light.svg, .dark.svg]
theme:
  - light: flatly
  - dark: darkly
If you want to apply it to your Quarto website, you should include the page themes options in your _quarto.yml file and add the knitr options to each post where you need the dual plots theme option.
  1. Add your ggplot2 themes and customize it to your likening.
light_theme <- function() {
  ggthemes::theme_solarized_2() %+% 
    theme(
      plot.background = element_rect(fill = "#FFF1E5"),
      panel.border = element_blank(),
      axis.line = element_line(colour = "#586e75",
                               linetype = 1),
      axis.ticks = element_line(colour = "#586e75"),
      axis.text = element_text(colour = "#002b36"),
      legend.background = element_rect(fill = "#FFF1E5"))
} 

theme_set(light_theme())

lightsvglite <- function(file, width, height) {
  on.exit(reset_theme_settings())
  theme_set(light_theme())
  ggsave(
    filename = file,
    width = width,
    height = height,
    dev = "svg",
    bg = "transparent"
  )
}
theme_dark <- function() {
  ggthemes::theme_solarized_2(light = F) %+%
    theme(
      text = element_text(colour = "white"),
      axis.text = element_text(colour = "white"),
      axis.title = element_text(colour = "white"),
      legend.text = element_text(colour = "white"),
      legend.title = element_text(colour = "white"),
      strip.text = element_text(colour = "white"),
      rect = element_rect(colour = "#272b30", fill = "#272b30"),
      plot.background = element_rect(fill = "#222222", colour = NA),
      axis.line = element_line(colour = "white"),
      axis.ticks = element_line(colour = "white"),
      plot.title = element_text(colour = "white"),
      plot.subtitle = element_text(colour = "white"),
      plot.caption = element_text(colour = "white"),
      legend.background = element_rect(fill = "#222222")
    )
}

darksvglite <- function(file, width, height) {
  on.exit(reset_theme_settings())
  theme_set(theme_dark())
  ggsave(
    filename = file,
    width = width,
    height = height,
    dev = "svg",
    bg = "transparent"
  )
}
For Quarto Websites

You can store the ggplot2 themes R scripts in a folder and call it in each post by using the source() function or even creating a personal package containing what you need.

  1. Finally add this to your YAML header to include the Javascript code in your Quarto document and you’re good to go!
include-after-body:
  text: |
    <script type="application/javascript" src="light-dark.js"></script>
Javascript code
// Author: Aymen Nasri
// Version: <1.1.0>
// Description: Change plots theme depending on body class (quarto-light or quarto-dark)
// Originally made by Mickaël Canouil
// License: MIT

function updateImageSrc() {
  // Identifying which theme is on
  const isLightMode = document.body.classList.contains('quarto-light');
  const isDarkMode = document.body.classList.contains('quarto-dark');
  
  if (!isLightMode && !isDarkMode) return; // Exit if neither mode is active

  // Function to update styles
  const updateElements = (selector, updateFunc) => {
    document.querySelectorAll(selector).forEach(updateFunc);
  };

  // Function to replace the plots depending on theme
  updateElements('img', img => {
    const newSrc = img.src.replace(isLightMode ? '.dark' : '.light', isDarkMode ? '.dark' : '.light');
    if (newSrc !== img.src) img.src = newSrc;
  });

  // Update ggplot
  const updateStyle = (elem, prop, lightValue, darkValue) => {
    const currentValue = elem.style[prop];
    const newValue = isDarkMode ? darkValue : lightValue;
    if (currentValue !== newValue) elem.style[prop] = newValue;
  };

  // Update ploly background color for both the plot and the legend box
  updateElements('svg[style*="background"]', svg => updateStyle(svg, 'background', 'rgb(255, 241, 229)', 'rgb(34, 34, 34)'));
  updateElements('rect[style*="fill"]', rect => updateStyle(rect, 'fill', 'rgb(255, 241, 229)', 'rgb(34, 34, 34)'));

  // Save the original plotly styling
  updateElements('text[class*="legendtext"], svg text, svg tspan', text => {
    if (!text.dataset.originalStyle) {
      const computedStyle = window.getComputedStyle(text);
      text.dataset.originalStyle = JSON.stringify({
        fill: computedStyle.fill,
        color: computedStyle.color,
        fontSize: computedStyle.fontSize,
        fontWeight: computedStyle.fontWeight,
        fontFamily: computedStyle.fontFamily,
        textDecoration: computedStyle.textDecoration
      });
    }

    const originalStyle = JSON.parse(text.dataset.originalStyle);

    // Modify the text colors for plotly labels
    if (isDarkMode) {
      text.style.fill = 'white';
      text.style.color = 'white';
    } else {
      Object.assign(text.style, originalStyle);
    }
  });

  // Update table text color
  updateElements('.gt_table_body, .gt_heading, .gt_sourcenotes, .gt_footnotes', table => {
    if (isDarkMode) {
      table.style.color = 'white'; // Set text color to a light shade
    } else {
      table.style.color = ''; // Reset to default
    }
  });

}

// Observer making sure all changes are done
const observer = new MutationObserver(mutations => {
  if (mutations.some(mutation => 
      (mutation.type === 'attributes' && mutation.attributeName === 'class') ||
      (mutation.type === 'childList' && mutation.target.tagName === 'svg'))) {
    updateImageSrc();
  }
});

observer.observe(document.body, {
  attributes: true,
  childList: true,
  subtree: true
});

// Run on page load and immediately
document.addEventListener('DOMContentLoaded', updateImageSrc);
updateImageSrc();

Scatter plot on a light background with light mode switched on.

Light mode ON

Scatter plot on a dark background with dark mode switched on.

Dark mode ON

What if you have other figures not included in the script?

A useful tool for my work was the Web Developer Tools on Firefox where I inspected different parts of the rendered HTML file to later include them in the script by name, you could use that in order to add more support for different figures.

You can visit my Github repo (link under the table of contents) and contact me there if you need something.