Making Mermaid.JS diagrams responsive to theme changes
Mermaid diagrams are used throughout Cloudflare’s documentation.
Out of the box, Mermaid will look for <pre class="mermaid">...def...</pre>
elements and replace the diagram definition text with a SVG. This is ‘destructive’ in that the definition is no-longer available, and if you want to re-render the diagram when the theme changes, that’s a problem! Let’s take a look at how we implement Mermaid diagrams in our developer docs:
Turning ```mermaid into <pre class="mermaid">
When writing Mermaid diagrams in MDX, we use a rehype plugin for two purposes:
- Prevent Expressive Code (our code block library) from trying to render them
- Make it easier for contributors (no need to wrap the definition in a component)
Whilst this plugin can render the diagrams server-side (with a headless browser), that tends to be pretty flaky. We use the pre-mermaid strategy so we can render them on the client-side with the Mermaid library.
Rendering diagrams with Mermaid
This is the easiest part! Since we’ve already prepared the diagrams into the right shape for the Mermaid library to consume, our script tag is as simple as:
import mermaid from "mermaid";
mermaid.initialize({ theme: "neutral" });
We’re stuck with a single theme though! Neutral is pretty close to looking good on both light and dark backgrounds, but suffers from some contrast issues. We’d really prefer to have different themes per background colour!
Reacting to theme changes
We use the Starlight theme for Astro which comes with a theme select out of the box. My first reaction was that all I need to do is:
- Add an event listener on the select element
- If the value is auto , use the matchMedia method to detect what the client’s
prefer-color-scheme
value is.
This sounds simple, but we’re kinda just duplicating the implementation of the theme select… which adds a data attribute to the root element, data-theme
.
So, let’s use MutationObserver
!
The MutationObserver interface provides the ability to watch for changes being made to the DOM tree. It is designed as a replacement for the older Mutation Events feature, which was part of the DOM3 Events specification.
When provided with attributes: true
and attributeFilter: ["data-theme"]
, it will fire when the theme is changed via the built-in select. The implementation of rendering Mermaid diagrams multiple times is a little more complicated than a simple mermaid.initialize
call, but it’s relatively brief.
Setup our MutationObserver
const obs = new MutationObserver(() => render());
obs.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"],});
By observing the root element and only firing our render
callback when the data-theme
attribute has changed, this allows us to not re-implement the event listeners that the theme select has already done.
Storing the diagram definition for re-use
const diagrams = document.querySelectorAll<HTMLPreElement>("pre.mermaid");
let init = false;
async function render() { ...
for (const diagram of diagrams) { if (!init) { diagram.setAttribute("data-diagram", diagram.textContent as string); }
... }
init = true;}
If you’re wondering why we use textContent
rather than innerText
, it’s because we apply visibility: hidden
to unprocessed Mermaid diagrams to prevent a flash of unstyled content!
Rendering the Mermaid diagram
async function render() { const theme = document.documentElement.getAttribute("data-theme") === "light" ? "neutral" : "dark";
for (const diagram of diagrams) { if (!init) { diagram.setAttribute("data-diagram", diagram.textContent as string); }
const def = diagram.getAttribute("data-diagram") as string;
mermaid.initialize({ startOnLoad: false, theme }); await mermaid .render(`mermaid-${crypto.randomUUID()}`, def) .then(({ svg }) => (diagram.innerHTML = svg));
diagram.setAttribute("data-processed", "true"); }
init = true;}
We’re still using initialize
here but only to set the theme, hence the startOnLoad: false
. The render method provides us with a RenderResult that has a svg string - which we replace the innerHTML of the pre element with.
Our code in action
As a bonus, these are also reactive to changes in the OS theme (via prefers-color-scheme
) due to Starlight’s theme select handling those events for us! All we do is watch the data-theme
attribute.