Article cover image

How to add Mermaid diagrams support to your Hugo site

Author profile image
Aitor Alonso

11 min read

RSS feed

A while ago I was writing an article about managing technical debt and I wanted to put a couple of decision flowcharts in it. My first instinct was to do what I did back in 2024 for the binary search trees article: draw the diagram in the Mermaid live editor, export it as an SVG, and drop the file into the images folder.

It works, but it’s annoying. The image doesn’t follow the site’s light/dark theme (mine looked completely out of place in light mode), and every small fix meant going back to the editor and re-exporting. So I decided to render Mermaid natively instead, and it turned out to be much less work than I expected. Let me show you how to add it to your own Hugo site. Let’s dive in!

What we’re actually building

Mermaid is a JavaScript library that turns text into diagrams. There’s no Hugo plugin or theme module involved here, just two pieces:

  1. Hugo turns your ```mermaid fenced blocks into a <pre class="mermaid"> element instead of a code block.
  2. A small script loads Mermaid in the browser and renders those elements into SVG.
flowchart LR
    A[Markdown fence] --> B[Codeblock render hook]
    B --> C[pre.mermaid in the HTML]
    C --> D[Mermaid script in the browser]
    D --> E([Rendered SVG diagram])

Click on diagram to enlarge

That diagram above is itself a ```mermaid block in the Markdown file of this article, so you’re already looking at the end result. Try swapping the site theme from/to light and dark to see how well it integrates. How did I achieve this?

Step 1: turn the fenced block into a diagram

Hugo has codeblock render hooks, templates that take over how a fenced code block is rendered, matched by the language of the fence. So I created one for mermaid, at layouts/_default/_markup/render-codeblock-mermaid.html:

<pre class="mermaid">{{- .Inner | htmlEscape | safeHTML -}}</pre>
{{- .Page.Store.Set "hasMermaid" true -}}

That’s the whole file. A few things worth explaining:

  • The file name is the matcher. render-codeblock-<language>.html is picked by Hugo for any fence whose info string is that language, so no shortcode is needed and your Markdown stays portable (a ```mermaid block still renders on GitHub, in your editor’s preview, etc).
  • .Inner is the raw text inside the fence.
  • htmlEscape matters more than it looks. Mermaid syntax is full of -->, <, { and }. Without escaping, the browser happily parses some of that as markup and your diagram breaks in creative ways. Then safeHTML tells Hugo the string is already escaped so it doesn’t escape it a second time. The browser decodes the entities back when parsing the page, and Mermaid reads the element’s textContent, so what it finally gets is the original source.
  • .Page.Store.Set just leaves a flag on the page saying “this one has a diagram”. We’ll use it in the next step.
Note
In Hugo 0.146 and newer, render hooks live in layouts/_markup/. If you’re on an older version, or your hook goes inside a theme with the classic structure (my case), the path is layouts/_default/_markup/render-codeblock-mermaid.html instead. Both still work today.
Warning

Codeblock render hooks only fire when markup.highlight.codeFences is true in your config. It is by default, so most likely you don’t need to do anything. But if you turned it off (I had, because I highlight code with Shiki as a post-build step instead of Hugo’s built-in Chroma), you need to set it back to true and add a generic fallback hook render-codeblock.html either under layouts/_markup/ or layouts/_default/_markup/ depending on your configuration, so every other language keeps rendering as a plain code block:


<pre><code class="language-{{ .Type }}">{{- .Inner | htmlEscape | safeHTML -}}</code></pre>

With that fallback in place, Hugo fires the hooks but never produces Chroma-highlighted markup, which is exactly what you want if something else is doing the highlighting.

Step 2: load Mermaid only where it’s needed

Mermaid is not a small library (roughly half a megabyte of minified JavaScript). Most of my articles don’t have a single diagram in them, so pulling it into every page would be a waste. That’s what the hasMermaid flag from step 1 is for.

In your single page template (layouts/_default/single.html, or whatever yours is called):

{{ .Content }} {{ if .Page.Store.Get "hasMermaid" }} {{ $js := resources.Get "js/mermaid.js" | js.Build (dict "format"
"esm" "minify" true) }}
<script type="module" src="{{ $js.RelPermalink }}" defer></script>
{{ end }}
Warning
The if must come after {{ .Content }}. Page.Store is only populated once the content has actually been rendered, and that happens the moment you call .Content. Put the check above it and the flag will be empty on every single page, with no error or warning to tell you why. Ask me how I know.

And now the script itself, at assets/js/mermaid.js:

const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"

const mermaid = (await import(MERMAID_CDN)).default

mermaid.initialize({ startOnLoad: false, securityLevel: "strict" })
await mermaid.run({ querySelector: "pre.mermaid" })

Note the version is pinned to a major (mermaid@11) rather than latest, so a future major release doesn’t silently change how your published diagrams look.

That’s it. Reload a page with a ```mermaid block in it and you should see a rendered diagram. If all you wanted was working diagrams, you can stop reading here, one template file and four lines of JavaScript is the whole minimum.

Step 3: make the diagrams follow your light/dark theme

The diagrams work now, but they look like Mermaid, not like your site. Mermaid ships a default and a dark theme and neither one is going to match your palette.

What you want instead is theme: "base" plus themeVariables, which lets you feed Mermaid your own colors. And since your palette almost certainly already lives in CSS custom properties, the nicest thing to do is read them from there, so you keep a single source of truth and the diagrams change along with the rest of the site whenever you touch the palette:

const isDark = () => document.documentElement.classList.contains("dark")
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim()

// Swap these custom property names for the ones your own theme defines.
function themeVariables() {
  const dark = isDark()
  return {
    background: "transparent",
    primaryColor: cssVar(dark ? "--surface-dark" : "--surface-light"),
    primaryBorderColor: cssVar(dark ? "--border-dark" : "--border-light"),
    primaryTextColor: cssVar(dark ? "--text-dark" : "--text-light"),
    lineColor: cssVar(dark ? "--line-dark" : "--line-light"),
    clusterBorder: cssVar(dark ? "--line-dark" : "--line-light"),
    titleColor: cssVar(dark ? "--text-dark" : "--text-light"),
    fontFamily: FONT_FAMILY,
  }
}

There are a lot of theme variables in Mermaid, but these are the ones that did most of the work for me:

  • background, which I set to transparent so the diagram just sits on the page background in both themes.
  • primaryColor, the fill of the nodes, with primaryBorderColor and primaryTextColor for their border and label.
  • lineColor, the arrows and edges.
  • clusterBorder and titleColor, the border and title of subgraphs. Easy to forget, and very visible when you do.
  • fontFamily, which has a gotcha of its own that I cover below.

If you use notes in sequence diagrams, or diagram types with alternate fills, you’ll also want noteBkgColor, noteTextColor, secondaryColor and tertiaryColor. The Mermaid theming docs list all of them.

Re-rendering when the reader flips the theme

If your site has a light/dark toggle, there’s one more thing. The colors are baked into the SVG at render time, so toggling the theme leaves the diagram sitting there in the old palette while everything around it changes. We need to render it again, and that means solving two small problems.

The first one is that Mermaid replaces the contents of the <pre> with the SVG, so by the time you want to re-render, the original diagram source is gone. The fix is to stash it in a data attribute before the first render:

const SOURCE_ATTR = "data-mermaid-source"

function captureSources() {
  document.querySelectorAll("pre.mermaid").forEach((pre) => {
    if (!pre.hasAttribute(SOURCE_ATTR)) pre.setAttribute(SOURCE_ATTR, pre.textContent)
  })
}

function restoreSources() {
  document.querySelectorAll("pre.mermaid").forEach((pre) => {
    pre.removeAttribute("data-processed")
    pre.textContent = pre.getAttribute(SOURCE_ATTR)
  })
}

data-processed is the attribute Mermaid stamps on an element once it has rendered it, and it skips anything already carrying it. Removing it is what makes the element eligible again.

The second problem is noticing the change at all. A MutationObserver on the <html> element does the job:

function observeThemeChanges() {
  let current = isDark()
  const observer = new MutationObserver(() => {
    if (isDark() === current) return
    current = isDark()
    restoreSources()
    void render()
  })
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
}

I compare against the previous value because the observer fires on any class change on <html>, and re-rendering every diagram on the page because some unrelated class was added would be silly. If your toggle sets something like data-theme="dark" instead of a class, adjust attributeFilter and isDark accordingly.

The whole script

Putting all the pieces together, this is the complete assets/js/mermaid.js:

const MERMAID_CDN = "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
const SOURCE_ATTR = "data-mermaid-source"

// Must resolve to the same stack the rendered SVG inherits (see the gotchas below).
const FONT_FAMILY = "ui-sans-serif, system-ui, sans-serif"

let mermaid

const isDark = () => document.documentElement.classList.contains("dark")
const cssVar = (name) => getComputedStyle(document.documentElement).getPropertyValue(name).trim()

// Swap these custom property names for the ones your own theme defines.
function themeVariables() {
  const dark = isDark()
  return {
    background: "transparent",
    primaryColor: cssVar(dark ? "--surface-dark" : "--surface-light"),
    primaryBorderColor: cssVar(dark ? "--border-dark" : "--border-light"),
    primaryTextColor: cssVar(dark ? "--text-dark" : "--text-light"),
    lineColor: cssVar(dark ? "--line-dark" : "--line-light"),
    clusterBorder: cssVar(dark ? "--line-dark" : "--line-light"),
    titleColor: cssVar(dark ? "--text-dark" : "--text-light"),
    fontFamily: FONT_FAMILY,
  }
}

function captureSources() {
  document.querySelectorAll("pre.mermaid").forEach((pre) => {
    if (!pre.hasAttribute(SOURCE_ATTR)) pre.setAttribute(SOURCE_ATTR, pre.textContent)
  })
}

function restoreSources() {
  document.querySelectorAll("pre.mermaid").forEach((pre) => {
    pre.removeAttribute("data-processed")
    pre.textContent = pre.getAttribute(SOURCE_ATTR)
  })
}

async function render() {
  mermaid.initialize({
    startOnLoad: false,
    securityLevel: "strict",
    theme: "base",
    themeVariables: themeVariables(),
  })
  await mermaid.run({ querySelector: "pre.mermaid" })
}

function observeThemeChanges() {
  let current = isDark()
  const observer = new MutationObserver(() => {
    if (isDark() === current) return
    current = isDark()
    restoreSources()
    void render()
  })
  observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] })
}

async function init() {
  captureSources()
  mermaid = (await import(MERMAID_CDN)).default
  await render()
  observeThemeChanges()
}

if (document.readyState === "loading") {
  document.addEventListener("DOMContentLoaded", () => void init())
} else {
  void init()
}

The gotchas that cost me time

A few things bit me along the way, so here they are so they don’t bite you too.

Labels overflowing their boxes

This one drove me mad for a while. Mermaid measures the text to decide how big each node box should be, and then the browser renders the SVG using whatever font it inherits from the page. If those two fonts aren’t the same, the label simply doesn’t fit inside the box it was given.

In my case the diagram lives inside a <pre>, and my prose styles (Tailwind Typography) set <pre> to a monospace font, which is wider than the sans-serif Mermaid had measured with. The fix is to make both sides agree:

pre.mermaid {
  font-family: ui-sans-serif, system-ui, sans-serif;
}

and pass that exact same stack as fontFamily in your theme variables. And yes, it has to be a concrete value there and not a var(--font-sans), because Mermaid does its measuring in a detached element where your CSS variable doesn’t resolve.

The flash of raw diagram source

Between the page loading and Mermaid finishing its work, the reader sees the raw diagram text as plain text, and then a layout jump when the SVG replaces it. Not great. One line of CSS hides it until it’s ready:

pre.mermaid:not([data-processed]) {
  visibility: hidden;
}
Warning
If you hide the source, keep in mind a failed CDN load now leaves a permanent blank gap where the diagram should be, instead of readable text. You could use setTimeout to flips the visibility back on if nothing has rendered after a few seconds, so the worst case is a reader seeing the diagram source rather than an empty hole in the article.

The bundler and the CDN import

If you build the script with js.Build like I do, be careful with how you import Mermaid. A static import mermaid from "https://..." makes esbuild try to resolve that URL at build time, and it fails. Importing from a variable (await import(MERMAID_CDN)) keeps it as a plain runtime import that the browser resolves instead.

If you’d rather not think about any of this, drop the file in static/js/ and point a <script type="module"> at it directly, skipping the bundler entirely.

Wrapping up

And that’s the whole thing: one render hook, one flag, and about sixty lines of JavaScript. No plugin and no theme module to keep updated.

What I like most about it isn’t the diagrams themselves, it’s that they now live in the Markdown file right next to the prose. They’re diffable in a pull request, I can fix a typo in a node without opening an editor, and they pick up the theme like everything else on the page. It was good enough that I went back to that 2024 binary search trees article and replaced the exported SVGs with live diagrams.

If you want to see it working, the flowcharts in how to manage technical debt and the trees in the binary search trees article are all rendered this way. Toggle the theme while you’re there and watch them re-render.

Now go and draw something. Until next time!