Light/dark theme for MermaidJS
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:
index-light-N.svg(the light svg rendition of the figure)index-dark-N.svg(the dark svg rendition of the figure)
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:
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.