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 :
- 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
- 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"
)
}
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.
- 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(
? ".dark" : ".light",
isLightMode ? ".dark" : ".light",
isDarkMode ;
)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);
.dataset.originalStyle = JSON.stringify({
textfill: 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) {
.style.fill = "white";
text.style.color = "rgb(204,193,183)";
textelse {
} Object.assign(text.style, originalStyle);
};
})
// Update table text color
updateElements(
".gt_table_body, .gt_heading, .gt_sourcenotes, .gt_footnotes",
=> {
(table) if (isDarkMode) {
.style.color = "white"; // Set text color to a light shade
tableelse {
} .style.color = ""; // Reset to default
table
},
};
)
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) {
.style.backgroundColor = "rgb(50, 50, 50)";
input.style.color = "white";
input.style.borderColor = "rgba(255, 255, 255, 0.2)";
inputelse {
} .style.backgroundColor = "";
input.style.color = "";
input.style.borderColor = "";
input
};
})
// 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
.style.transition = "background-color 0.2s";
button
// 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')) {
.style.backgroundColor = originalBg;
btn
};
});
}
// Add click handler to reset all buttons first
.addEventListener('click', () => {
button// Small timeout to let reactable update the current button class
setTimeout(resetAllButtons, 50);
;
})
// Hover effects
.addEventListener('mouseenter', () => {
buttonif (!button.classList.contains('rt-page-button-current')) {
.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
button
};
})
.addEventListener('mouseleave', () => {
buttonif (!button.classList.contains('rt-page-button-current')) {
.style.backgroundColor = originalBg;
button
};
})
};
})
// Update all pagination buttons for hover/active states
updateElements(".rt-pagination button", (button) => {
if (isDarkMode) {
// Add hover effect via CSS
.style.transition = "background-color 0.2s";
button
// Add event listeners for hover/click states
.addEventListener('mouseenter', () => {
buttonif (!button.classList.contains('rt-page-button-current')) {
.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
button
};
})
.addEventListener('mouseleave', () => {
buttonif (!button.classList.contains('rt-page-button-current')) {
.style.backgroundColor = "";
button
};
})
// Style for active (clicked) state
.addEventListener('mousedown', () => {
button.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
button;
})
.addEventListener('mouseup', () => {
buttonif (!button.classList.contains('rt-page-button-current')) {
.style.backgroundColor = "rgba(255, 255, 255, 0.1)";
button
};
})else {
} // Remove event listeners in light mode
.style.transition = "";
button
};
})
// Update pagination buttons
updateElements(".rt-pagination-nav button", (button) => {
if (isDarkMode) {
.style.color = "rgba(255, 255, 255, 0.7)";
buttonelse {
} .style.color = "";
button
};
})
// For current page button
updateElements(".rt-page-button-current", (button) => {
if (isDarkMode) {
.style.backgroundColor = "rgba(255, 255, 255, 0.2)";
button.style.color = "white";
buttonelse {
} .style.backgroundColor = "";
button.style.color = "";
button
};
})
// Header sort indicators should be visible in dark mode
updateElements(".rt-sort-header", (header) => {
if (isDarkMode) {
.style.color = "white";
headerelse {
} .style.color = "";
header
};
});
})
}
// Observer making sure all changes are done
const observer = new MutationObserver((mutations) => {
if (
.some(
mutations=>
(mutation) .type === "attributes" &&
(mutation.attributeName === "class") ||
mutation.type === "childList" && mutation.target.tagName === "svg"),
(mutation
)
) {updateImageSrc();
};
})
.observe(document.body, {
observerattributes: true,
childList: true,
subtree: true,
attributeFilter: ["class"],
;
})
// Run on page load and immediately
document.addEventListener("DOMContentLoaded", updateImageSrc);
updateImageSrc();
Expected Results
{ggplot2} Plots
{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.