monotux.tech

Light/dark theme for MermaidJS

MermaidJS Hugo

This post is part of the Adding MermaidJS Without Any JS series.

Woodpecker CI, Hugo & MermaidJSAdding icons to Mermaid chartsLight/dark theme for MermaidJS

Making my graphs respect the user system light- and darkmode settings was surprisingly straight-forward, and we are still not using any (browser side) JavaScript to accomplish this!

We are essentially just using pre-generating light and dark SVGs with predictable names, then using HTML5 figure source sets with a media selector, enabling the browser to select which color scheme is preferred. I had a hunch that this could be done but got some help from an unethical LLM to make this a reality.

Table of Contents

Pre-generating images

This is just a modified version of the same woodpecker pipeline as described in earlier entries in this series. We are just running mermaidcli twice with different configurations, then using regex to change the output to use a custom shortcode which will render the correct HTML5 for us.

It’s not pretty but it does what it should.

#!/bin/bash
set -o errexit   # abort on nonzero exitstatus
set -o nounset   # abort on unbound variable
set -o pipefail  # don't hide errors within pipes

for md_file in $(grep -l -r mermaid content | grep -E "md$"); do
  dir=$(dirname $md_file)
  bas=$(basename $md_file)
  cd $dir

  cp $bas /tmp/$bas-dark.md

  /home/mermaidcli/node_modules/.bin/mmdc \
    --puppeteerConfigFile /puppeteer-config.json \
    -i $bas \
    -o $bas \
    --theme default \
    --backgroundColor transparent \
    --outputFormat svg \
    --iconPacks '@iconify-json/logos' \
    --iconPacksNamesAndUrls "azure#https://raw.githubusercontent.com/NakayamaKento/AzureIcons/refs/heads/main/icons.json"

  cp $bas /tmp/$bas-rendered.md

  bases=$(echo $bas | sed -e "s/\.md//")
  for light_file in $bases-[0-9]*.svg; do
    if [ -f "$light_file" ]; then
      num=$(echo $light_file | sed -E 's/.*-([0-9]+)\.svg/\1/')
      base_name=$(echo $bas | sed -e "s/\.md//")
      mv "$light_file" "$base_name-light-$num.svg"
    fi
  done

  cp /tmp/$bas-dark.md $bas

  echo "Generate dark versions"
  /home/mermaidcli/node_modules/.bin/mmdc \
    --puppeteerConfigFile /puppeteer-config.json \
    -i $bas \
    -o $bas \
    --theme dark \
    --backgroundColor transparent \
    --outputFormat svg \
    --iconPacks '@iconify-json/logos' \
    --iconPacksNamesAndUrls "azure#https://raw.githubusercontent.com/NakayamaKento/AzureIcons/refs/heads/main/icons.json"

  # Rename dark versions to include -dark suffix
  bases=$(echo $bas | sed -e "s/\.md//")
  for dark_file in $bases-[0-9]*.svg; do
    if [ -f "$dark_file" ]; then
      num=$(echo $dark_file | sed -E 's/.*-([0-9]+)\.svg/\1/')
      base_name=$(echo $bas | sed -e "s/\.md//")
      mv "$dark_file" "$base_name-dark-$num.svg"
    fi
  done

  cp /tmp/$bas-rendered.md $bas

  # Replace image references with adaptive-image shortcode
  sed -i -E '
    s|!\[([^]]*)\]\(\./([^ ]+) "([^"]+)"\)|\{\{< adaptive-image src="\2" alt="\1" caption="\3" >}}|g;
    s|!\[([^]]*)\]\(\./([^)]*)\)|\{\{< adaptive-image src="\2" alt="\1" >}}|g
  ' $bas
  cd -
done

After running this script we will, for each Mermaid figure in the entry, have:

Custom shortcode

I’ve saved this as layouts/shortcodes/adaptive-image.html, and it will render a working HTML figure with a sourceset that includes both light and dark versions of the image, which the browser will use based on the user’s system theme.

We won’t be calling this shortcode directly when writing a post, but the sed command at the end of the script in the previous section (above) replaces the default figure rendition with this adaptive-image shortcode.

{{- $src := .Get "src" -}}
{{- $src = replace $src "./" "" -}}

{{- $alt := .Get "alt" | default (.Get "caption") -}}
{{- $caption := .Get "caption" -}}

{{- $lightName := "" -}}
{{- $darkName := "" -}}

{{- /* Check if src matches pattern name-NUMBER.svg */ -}}
{{- if findRE "-[0-9]+\\.svg$" $src -}}
  {{- $lightName = $src | replaceRE "(-[0-9]+)\\.svg$" "-light${1}.svg" -}}
  {{- $darkName = $src | replaceRE "(-[0-9]+)\\.svg$" "-dark${1}.svg" -}}
{{- else -}}
  {{- /* Fallback: just add -light/-dark before .svg */ -}}
  {{- $lightName = $src | replaceRE "\\.svg$" "-light.svg" -}}
  {{- $darkName = $src | replaceRE "\\.svg$" "-dark.svg" -}}
{{- end -}}

{{- $imglight := .Page.Resources.GetMatch $lightName -}}
{{- $imgdark := .Page.Resources.GetMatch $darkName -}}

{{- /* Debug: check if resources were found */ -}}
{{- if not $imglight -}}
  {{- warnf "Could not find light image: %s" $lightName -}}
{{- end -}}
{{- if not $imgdark -}}
  {{- warnf "Could not find dark image: %s" $darkName -}}
{{- end -}}

<figure>
  <picture>
    {{- if and $imgdark $imglight -}}
    <source srcset="{{ $imgdark.RelPermalink }}" 
            media="(prefers-color-scheme: dark)">
    <source srcset="{{ $imglight.RelPermalink }}" 
            media="(prefers-color-scheme: light)">
    <img src="{{ $imglight.RelPermalink }}" 
         alt="{{ $alt }}">
    {{- else -}}
    {{- /* Fallback to original src if resources not found */ -}}
    <img src="{{ $src | relURL }}" 
         alt="{{ $alt }}">
    {{- end -}}
  </picture>
  {{- with $caption }}
  <figcaption>{{ . }}</figcaption>
  {{- end }}
</figure>

This will render something like below, and includes an img fallback if the media query is not supported:

<picture>
    <source srcset="https://www.monotux.tech/posts/2025/11/mermaid-icons/index-dark-1.svg"
            media="(prefers-color-scheme: dark)" />
    <source srcset="https://www.monotux.tech/posts/2025/11/mermaid-icons/index-light-1.svg"
            media="(prefers-color-scheme: light)" />
    <img src="https://www.monotux.tech/posts/2025/11/mermaid-icons/index-light-1.svg"
         alt="Look at all the icons!"/>
</picture>

And, to demonstrate that it works:

diagram

This should render according to your system theme preferences, and if you don’t allow your browser to be aware of your system theme preferences, it will default to the light theme.