Quarto Plots/Tables Theme Switcher

Writing a Javascript file to have light/dark themed plots (ggplot2 and plotly) and tables (gt and reactable) 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/reactable tables which required different treatment in the script as plotly produced SVG files and gt/reactable 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.2.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 = "rgb(204,193,183)";
    } 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
      }
    },
  );

  updateElements(".reactable", (table) => {
    // Update text colors in reactable table
    updateElements(
      ".rt-tbody .rt-tr, .rt-thead .rt-tr, .rt-pagination",
      (elem) => {
        updateStyle(elem, "color", "", isDarkMode ? "white" : "");
      },
    );

    // Update background color for the search input
    updateElements(".rt-search", (input) => {
      if (isDarkMode) {
        input.style.backgroundColor = "rgb(50, 50, 50)";
        input.style.color = "white";
        input.style.borderColor = "rgba(255, 255, 255, 0.2)";
      } else {
        input.style.backgroundColor = "";
        input.style.color = "";
        input.style.borderColor = "";
      }
    });
    
    // Update all pagination buttons for hover/active states
    updateElements(".rt-pagination button", (button) => {
      if (isDarkMode) {
        // Store original background to reset to
        const originalBg = "";
        
        // Add hover effect via CSS
        button.style.transition = "background-color 0.2s";
        
        // Function to reset backgrounds on all pagination buttons
        const resetAllButtons = () => {
          document.querySelectorAll('.rt-pagination button').forEach(btn => {
            if (!btn.classList.contains('rt-page-button-current')) {
              btn.style.backgroundColor = originalBg;
            }
          });
        };
        
        // Add click handler to reset all buttons first
        button.addEventListener('click', () => {
          // Small timeout to let reactable update the current button class
          setTimeout(resetAllButtons, 50);
        });
        
        // Hover effects
        button.addEventListener('mouseenter', () => {
          if (!button.classList.contains('rt-page-button-current')) {
            button.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
          }
        });
        
        button.addEventListener('mouseleave', () => {
          if (!button.classList.contains('rt-page-button-current')) {
            button.style.backgroundColor = originalBg;
          }
        });
      }
    });
    
    // Update all pagination buttons for hover/active states
    updateElements(".rt-pagination button", (button) => {
      if (isDarkMode) {
        // Add hover effect via CSS
        button.style.transition = "background-color 0.2s";
        
        // Add event listeners for hover/click states
        button.addEventListener('mouseenter', () => {
          if (!button.classList.contains('rt-page-button-current')) {
            button.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
          }
        });
        
        button.addEventListener('mouseleave', () => {
          if (!button.classList.contains('rt-page-button-current')) {
            button.style.backgroundColor = "";
          }
        });
        
        // Style for active (clicked) state
        button.addEventListener('mousedown', () => {
          button.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
        });
        
        button.addEventListener('mouseup', () => {
          if (!button.classList.contains('rt-page-button-current')) {
            button.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
          }
        });
      } else {
        // Remove event listeners in light mode
        button.style.transition = "";
      }
    });
      
    // Update pagination buttons
    updateElements(".rt-pagination-nav button", (button) => {
      if (isDarkMode) {
        button.style.color = "rgba(255, 255, 255, 0.7)";
      } else {
        button.style.color = "";
      }
    });

    // For current page button
    updateElements(".rt-page-button-current", (button) => {
      if (isDarkMode) {
        button.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
        button.style.color = "white";
      } else {
        button.style.backgroundColor = "";
        button.style.color = "";
      }
    });

    // Header sort indicators should be visible in dark mode
    updateElements(".rt-sort-header", (header) => {
      if (isDarkMode) {
        header.style.color = "white";
      } else {
        header.style.color = "";
      }
    });
  });
}

// 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,
  attributeFilter: ["class"],
});

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

Expected Results

{ggplot2} Plots

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

Switch the light/dark mode from the navbar to see the results.

{plotly} Plots

{gt} Tables

mfr model year trim bdy_style hp ctry_origin
Ford GT 2017 Base Coupe coupe 647 United States
Ferrari 458 Speciale 2015 Base Coupe coupe 597 Italy
Ferrari 458 Spider 2015 Base convertible 562 Italy
Ferrari 458 Italia 2014 Base Coupe coupe 562 Italy
Ferrari 488 GTB 2016 Base Coupe coupe 661 Italy
Ferrari California 2015 Base Convertible convertible 553 Italy

{reactable} Tables

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.