Quarto Q&A: How to have images for both light and dark theme?

In this blog post of the “Quarto Q&A” series you will learn how to generate images for light and dark theme using knitr and switch between them when changing theme.

Quarto
Q&A
JavaScript
theme
dark mode
light mode
Author

Mickaël CANOUIL

Published

Tuesday, the 30th of May, 2023

A new blog post of the “Quarto Q&A” series.
This time, I will show how to have images both for light and dark theme, when switching between them.

Animated GIF showing a ggplot2 figure switching from light to dark on theme toggle switch.

1 The Question/Problem

When you build a website with Quarto, you can use the theme option to specify the theme you want to use for light and dark mode1.

theme:
  light: united
  dark: slate

However, if you want to have images for both themes, you need to have two versions of the same image, one for light and one for dark mode. But how can you do that and how can you switch between them automatically?

2 The Answer/Solution

Let’s use knitr2 as the engine to generate the images. For example, you can use svglite to generate SVG images and/or any custom knitr handler.

  1. Set the dev option to use svglite for light mode and darksvglite for dark mode and fig.ext to set the generated images extensions.

    knitr: 
      opts_chunk: 
        dev: [svglite, darksvglite]
        fig.ext: [.light.svg, .dark.svg]
  2. Create ggplot2 themes for light and dark images.

    theme_light <- function() {
      theme_minimal(base_size = 11) %+%
      theme(
        panel.border = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.minor.y = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.minor.x = element_blank(),
        text = element_text(colour = "black"),
        axis.text = element_text(colour = "black"),
        rect = element_rect(colour = "white", fill = "black"),
        plot.background = element_rect(fill = "white", colour = NA),
        axis.line = element_line(colour = "black"),
        axis.ticks = element_line(colour = "black")
      )
    }
    theme_dark <- function() {
      theme_minimal(base_size = 11) %+%
      theme(
        panel.border = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.minor.y = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.minor.x = element_blank(),
        text = element_text(colour = "white"),
        axis.text = element_text(colour = "white"),
        rect = element_rect(colour = "#272b30", fill = "#272b30"),
        plot.background = element_rect(fill = "#272b30", colour = NA),
        axis.line = element_line(colour = "white"),
        axis.ticks = element_line(colour = "white")
      )
    }
  3. Create a new function to save the dark images.

    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"
      )
    }
  4. The ggplot2 code to build an image.

    library(ggplot2)
    library(palmerpenguins)
    theme_set(theme_light())
    ggplot(data = penguins) +
      aes(
        x = flipper_length_mm,
        y = body_mass_g / 1e3,
        colour = species,
        shape = species
      ) +
      geom_point(alpha = 0.8, na.rm = TRUE) +
      scale_colour_manual(values = c("darkorange", "purple", "cyan4")) +
      labs(
        x = "Flipper Length (mm)",
        y = "Body Mass (kg)",
        color = "Penguin Species",
        shape = "Penguin Species"
      )
  5. The JavaScript code to switch between the two images (i.e., .light.svg and .dark.svg)3.

    JavaScript code

    // Author: Mickaël Canouil
    // Version: <1.0.0>
    // Description: Change image src depending on body class (quarto-light or quarto-dark)
    // License: MIT
    function updateImageSrc() {
      var bodyClass = window.document.body.classList;
      var images = window.document.getElementsByTagName('img');
      for (var i = 0; i < images.length; i++) {
        var image = images[i];
        var src = image.src;
        var newSrc = src;
        if (bodyClass.contains('quarto-light') && src.includes('.dark')) {
          newSrc = src.replace('.dark', '.light');
        } else if (bodyClass.contains('quarto-dark') && src.includes('.light')) {
          newSrc = src.replace('.light', '.dark');
        }
        if (newSrc !== src) {
          image.src = newSrc;
        }
      }
    }
    
    var observer = new MutationObserver(function(mutations) {
      mutations.forEach(function(mutation) {
        if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
          updateImageSrc();
        }
      });
    });
    
    observer.observe(window.document.body, {
      attributes: true
    });
    
    updateImageSrc();
  6. Finally, the whole Quarto document code.

    Quarto document code

    ---
    title: "Generated images for dark and light mode"
    format:
      html:
        theme:
          light: united
          dark: slate
    execute:
      echo: false
      warning: false
    knitr: 
      opts_chunk: 
        dev: [svglite, darksvglite]
        fig.ext: [.light.svg, .dark.svg]
    include-after-body:
      text: |
        <script type="application/javascript" src="light-dark.js"></script>
    ---
    
    ```{r}
    #| include: false
    library(ggplot2)
    theme_light <- function() {
      theme_minimal(base_size = 11) %+%
      theme(
        panel.border = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.minor.y = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.minor.x = element_blank(),
        text = element_text(colour = "black"),
        axis.text = element_text(colour = "black"),
        rect = element_rect(colour = "white", fill = "black"),
        plot.background = element_rect(fill = "white", colour = NA),
        axis.line = element_line(colour = "black"),
        axis.ticks = element_line(colour = "black")
      )
    }
    
    theme_dark <- function() {
      theme_minimal(base_size = 11) %+%
      theme(
        panel.border = element_blank(),
        panel.grid.major.y = element_blank(),
        panel.grid.minor.y = element_blank(),
        panel.grid.major.x = element_blank(),
        panel.grid.minor.x = element_blank(),
        text = element_text(colour = "white"),
        axis.text = element_text(colour = "white"),
        rect = element_rect(colour = "#272b30", fill = "#272b30"),
        plot.background = element_rect(fill = "#272b30", colour = NA),
        axis.line = element_line(colour = "white"),
        axis.ticks = element_line(colour = "white")
      )
    }
    
    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"
      )
    }
    ```
    
    ```{r}
    #| column: screen-inset
    #| fig-width: !expr 16/2
    #| fig-height: !expr 9/2
    #| out-width: 80%
    library(ggplot2)
    library(palmerpenguins)
    theme_set(theme_light())
    ggplot(data = penguins) +
      aes(
        x = flipper_length_mm,
        y = body_mass_g / 1e3,
        colour = species,
        shape = species
      ) +
      geom_point(alpha = 0.8, na.rm = TRUE) +
      scale_colour_manual(values = c("darkorange", "purple", "cyan4")) +
      labs(
        x = "Flipper Length (mm)",
        y = "Body Mass (kg)",
        color = "Penguin Species",
        shape = "Penguin Species"
      )
    ```

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

    Light mode ON

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

    Dark mode ON

Footnotes

  1. See https://quarto.org/docs/output-formats/html-themes.html#dark-mode.↩︎

  2. What matters is that you have two different images, one for light and one for dark mode, respectively with .light and .dark in their names.↩︎

  3. The JavaScript code does not actually look for the images extensions, but for the images names.↩︎