Adding Mermaid diagram support to an Astro blog
Client-side rendering, a loading skeleton, theme toggle re-rendering, and a fullscreen lightbox
- Published
- 28 May 2026
- Read time
- 12 min read
Was this useful?
Mermaid lets you write diagrams as text: sequence diagrams, flowcharts, ER diagrams, rendered as SVG. For a technical blog that explains architectures and flows, being able to write diagrams in Markdown the same way you write code blocks is a significant quality-of-life improvement.
This post covers how I added Mermaid support to this blog, why I chose client-side rendering over a build-time rehype plugin, and the specific problems that came up along the way.
Architecture overview
The remark plugin handles everything at build time: it intercepts ```mermaid blocks
before Expressive Code sees them and converts each one to a static HTML container.
At runtime, the CDN-loaded Mermaid library reads each container’s data-mermaid
attribute and replaces it with an SVG.
Why not a plugin?
The first-party options are rehype-mermaid and @beoe/rehype-mermaid. Both work:
they process Mermaid blocks at build time and emit static SVG. The trade-off is that
they require a headless browser (Playwright or Puppeteer) to render the SVG, which
adds a heavy build dependency and complicates the Netlify build environment.
The client-side approach, installing the mermaid package and rendering on page load,
is simpler, builds anywhere, and is fast enough for a blog. Mermaid is served from a
CDN via a <script defer> tag, so nothing extra enters the Astro build pipeline.
Bypassing Expressive Code for mermaid blocks
This blog uses Expressive Code for syntax
highlighting. EC processes every fenced code block, including ```mermaid,
wrapping it in its own component output. That means the raw
<pre><code class="language-mermaid"> element the client-side Mermaid renderer
needs never makes it to the DOM.
EC has no built-in exclude list, so the solution is a small remark plugin that
runs before EC in the pipeline, converting mermaid code nodes into raw HTML. The
plugin stores the diagram definition in a data-mermaid attribute and injects a
loading skeleton as the initial child:
function escapeAttr(str) { return str .replace(/&/g, '&') .replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>');}
export function remarkMermaid() { return (tree) => { function walk(node, parent, index) { if (node.type === 'code' && node.lang === 'mermaid' && parent) { const titleMatch = node.meta?.match(/title="([^"]+)"/); const title = titleMatch?.[1] ?? null; parent.children[index] = { type: 'html', value: `<div class="mermaid-diagram" data-loading data-mermaid="${escapeAttr(node.value)}"` + (title ? ` data-title="${escapeAttr(title)}"` : '') + `>${SKELETON}</div>`, }; return; } if (node.children) { node.children.forEach((child, i) => walk(child, node, i)); } } walk(tree, null, null); };}Register it in astro.config.mjs. syntaxHighlight: false is also needed to
prevent Astro’s built-in Shiki from conflicting with Expressive Code:
import { remarkMermaid } from './src/plugins/remark-mermaid.js';
export default defineConfig({ markdown: { remarkPlugins: [remarkMermaid], syntaxHighlight: false, },});Because remark runs before rehype (where EC operates), mermaid blocks are already
opaque HTML by the time EC processes the document. EC never sees them. The plugin
emits the final container element at build time, storing the diagram definition as a
data-mermaid attribute: no DOM manipulation is needed at runtime.
A loading skeleton while Mermaid initialises
SKELETON (defined at the top of the plugin file) is a string of HTML: an animated
placeholder with three rows of nodes connected by vertical connectors. Here is the
shape of what the plugin emits for each mermaid block:
<div class="mermaid-diagram" data-loading data-mermaid="sequenceDiagram..."> <div class="mermaid-skeleton" aria-hidden="true"> <div class="mermaid-skeleton__row"><!-- top node row --></div> <div class="mermaid-skeleton__connector"></div> <div class="mermaid-skeleton__row"><!-- middle nodes --></div> <div class="mermaid-skeleton__connector"></div> <div class="mermaid-skeleton__row"><!-- bottom nodes --></div> </div></div>The CSS targets [data-loading] to show the shimmer animation and set
color: transparent, which hides the definition text that briefly occupies the
container between skeleton removal and SVG insertion:
:global(.mermaid-diagram[data-loading]) { color: transparent;}Because this HTML ships with the page, the skeleton is visible from the first paint, before any JavaScript runs. There is no blank space and no layout shift where the diagram will appear.
renderDiagrams() re-applies data-loading at the start of each call, so
re-renders triggered by a theme change also show the skeleton rather than a flash of
definition text. The attribute is removed after mermaid.run() resolves.
Installing and initialising Mermaid
pnpm add mermaidIn MarkdownPostLayout.astro, a <script> block handles the rendering. Astro
bundles these component scripts automatically.
Only the type is imported from the package:
import type { MermaidConfig } from 'mermaid';Mermaid itself is not imported as an ES module. A local type describes the slice of
the API we use, and renderDiagrams() resolves the value from window.mermaid,
polling every 10ms until it is available:
type MermaidApi = { initialize: (config: unknown) => void; run: (options: { nodes: Element[] }) => Promise<void>;};
const mermaid = await new Promise<MermaidApi>((resolve) => { if (window.mermaid) { resolve(window.mermaid); return; } const id = setInterval(() => { if (window.mermaid) { clearInterval(id); resolve(window.mermaid); } }, 10);});This is necessary because Mermaid is loaded via a <script defer> tag: the CDN
script may not have executed by the time the page’s own bundle runs.
startOnLoad: false in the config prevents Mermaid from scanning and rendering on
DOMContentLoaded. The setup code needs to run first.
Reading definitions and preparing expand buttons
Because the remark plugin emits <div class="mermaid-diagram" data-loading data-mermaid="..."> containers
with the definition stored as an attribute, the client-side setup is
straightforward: query those containers, read each definition, create the expand
button, and store everything in a Map keyed by the container element:
const definitions = new Map<HTMLElement, { definition: string; btn: HTMLButtonElement }>();
document .querySelectorAll<HTMLElement>('.post__content .mermaid-diagram[data-mermaid]') .forEach((container) => { const definition = container.dataset.mermaid!;
const expandBtn = document.createElement('button'); expandBtn.className = 'media-expand-btn'; expandBtn.setAttribute('aria-label', 'View diagram fullscreen');
definitions.set(container, { definition, btn: expandBtn }); });Storing the expand button alongside the definition avoids recreating it on every
re-render: after mermaid.run() resolves, the existing button is simply
re-appended to the updated container.
renderDiagrams reinitialises Mermaid with the current theme, restores the
definition text into each container (which mermaid.run() replaces with SVG), then
appends the button after rendering completes:
async function renderDiagrams() { const isDark = document.documentElement.dataset.theme === 'dark'; mermaid.initialize(getMermaidConfig(isDark));
definitions.forEach(({ definition }, container) => { container.removeAttribute('data-processed'); container.setAttribute('data-loading', ''); container.textContent = definition; });
await mermaid.run({ nodes: [...definitions.keys()] });
definitions.forEach(({ btn }, container) => { container.removeAttribute('data-loading'); container.appendChild(btn);
const svgEl = container.querySelector('svg'); if (svgEl) { const titleText = container.dataset.title; if (titleText) { const titleId = `mermaid-title-${Math.random().toString(36).slice(2, 8)}`; const titleEl = document.createElementNS('http://www.w3.org/2000/svg', 'title'); titleEl.id = titleId; titleEl.textContent = titleText; svgEl.prepend(titleEl); svgEl.setAttribute('role', 'img'); svgEl.setAttribute('aria-labelledby', titleId); } else { svgEl.setAttribute('role', 'img'); svgEl.setAttribute('aria-label', 'Diagram'); } } });}
await renderDiagrams();Theming for light and dark mode
Mermaid bakes theme colours into the SVG at render time, so a CSS variable change alone won’t update an already-rendered diagram when the user switches themes. The right approach is to pass the correct theme at initialisation time, then re-run the whole render when the theme changes.
A helper function returns the config for the current theme. The Mermaid type import
is used only for the return type: MermaidConfig keeps the theme property typed
as "dark" | "default" | "base" | "forest" | "neutral" | "null", not a plain
string:
import type { MermaidConfig } from 'mermaid';
const mermaidThemePalette = { dark: { primaryColor: '#3d1528', primaryTextColor: '#abb2bf', lineColor: '#767c89', secondaryColor: '#2c313a', titleColor: '#dcdcdc', }, light: { primaryColor: '#fce8f2', primaryTextColor: '#383a42', lineColor: '#6b6b6b', secondaryColor: '#f0f0f0', titleColor: '#383a42', },} as const;
function getMermaidConfig(isDark: boolean): MermaidConfig { const palette = isDark ? mermaidThemePalette.dark : mermaidThemePalette.light; const background = getComputedStyle(document.documentElement) .getPropertyValue(isDark ? '--surface-media-stage-dark' : '--surface-media-stage') .trim();
return { startOnLoad: false, theme: 'base', darkMode: isDark, look: 'handDrawn', themeVariables: { background, primaryColor: palette.primaryColor, primaryTextColor: palette.primaryTextColor, lineColor: palette.lineColor, secondaryColor: palette.secondaryColor, titleColor: palette.titleColor, fontFamily: '"Barlow Condensed", sans-serif', }, };}Using theme: 'base' with explicit themeVariables gives full control over the
colour system. The hardcoded palette values complement the site’s accent pink:
primaryColor is a deep rose in dark mode and a pale blush in light mode.
fontFamily ties diagram text to the site’s heading typeface, Barlow Condensed.
look: 'handDrawn' enables Mermaid’s hand-drawn rendering style where supported.
A CSS fallback layer covers SVG elements that bypass Mermaid’s themeVariables
system, mainly sequence diagram lines and text that have their own rendering path:
:global(.mermaid-diagram text),:global(.mermaid-diagram .messageText),:global(.mermaid-diagram .labelText),:global(.mermaid-diagram .loopText),:global(.mermaid-diagram .noteText) { fill: var(--text-primary) !important; stroke: none !important;}
:global(.mermaid-diagram line),:global(.mermaid-diagram path.arrowMarkerPath),:global(.mermaid-diagram .messageLine0),:global(.mermaid-diagram .messageLine1) { stroke: var(--text-primary) !important;}var(--text-primary) resolves correctly in both light and dark contexts without
any JavaScript.
Re-rendering on theme toggle
Because Mermaid bakes colours into the SVG at render time, toggling between light and dark mode after the page loads leaves diagrams in the wrong theme. The solution is to watch for theme changes and re-render from stored definitions.
The theme toggle in this site sets data-theme directly on <html> via
setAttribute; no custom event is dispatched. A MutationObserver watching the
data-theme attribute is the only reliable way to detect the change from inside a
layout component’s script:
new MutationObserver(async (mutations) => { for (const m of mutations) { if (m.attributeName === 'data-theme') { await renderDiagrams(); break; } }}).observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'],});No separate button-reattachment step is needed. renderDiagrams() already
re-appends each stored button from the definitions Map after mermaid.run()
resolves, so buttons are always present after any render, initial or triggered.
The result: diagrams re-render correctly every time the user toggles the theme.
The expand button and lightbox
Sequence diagrams, especially detailed ones, can be hard to read at the width of a prose column. Each rendered diagram gets an Expand button that opens a near-fullscreen lightbox.
The lightbox is a single DOM element created once and shared between diagram expand
and zoomable images. It carries role="dialog", aria-modal="true", and an
accessible aria-label, and supports three close mechanisms: the Close button,
clicking the dark overlay backdrop, and pressing Escape.
The expand handler clones the SVG, strips the constrained inline dimensions Mermaid
sets, and sizes it to fill the available viewport using the SVG’s viewBox:
expandBtn.addEventListener('click', () => { const svgClone = container.querySelector('svg')!.cloneNode(true) as SVGElement; svgClone.removeAttribute('style'); svgClone.removeAttribute('width'); svgClone.removeAttribute('height'); const viewBoxAttr = svgClone.getAttribute('viewBox'); if (viewBoxAttr) { const parts = viewBoxAttr.trim().split(/[\s,]+/).map(Number); if (parts.length === 4 && parts[2] > 0 && parts[3] > 0) { const rem = parseFloat(getComputedStyle(document.documentElement).fontSize); const availW = window.innerWidth * 0.98 - 4 * rem; const availH = window.innerHeight * 0.94 - 4 * rem; const scale = Math.min(availW / parts[2], availH / parts[3]); svgClone.style.width = `${Math.round(parts[2] * scale)}px`; svgClone.style.height = `${Math.round(parts[3] * scale)}px`; } } else { svgClone.style.width = '100%'; svgClone.style.height = 'auto'; } svgClone.style.display = 'block'; lightboxScroll.appendChild(svgClone); lightbox.classList.add('is-open'); document.documentElement.style.overflow = 'hidden'; document.body.style.overflow = 'hidden'; closeBtn.focus();});Reading viewBox gives the intrinsic diagram dimensions. Scaling both axes by
Math.min(availW / vbW, availH / vbH) fills as much of the viewport as possible
while maintaining aspect ratio. The removeAttribute calls are still necessary:
Mermaid also sets inline width and height that would override the computed
style values.
On close, the cloned SVG is removed from the DOM and scroll is restored on both
<html> and <body>.
Button styling
The expand button uses the shared .media-expand-btn class, which also styles the
expand button on zoomable images. The visual pattern matches the code block copy
button: dark-ink defaults in light mode, white-tinted values in dark mode, and a
pink hover state.
Using separate top-level :global() rules rather than nested &:hover inside
:global() is important: Astro’s scoped style processor doesn’t expand nested
selectors inside :global(), so the nested form is silently ignored.
:global(.media-expand-btn) { background: rgba(0, 0, 0, 0.06); border: 1px solid rgba(0, 0, 0, 0.18); color: rgba(0, 0, 0, 0.55); /* ... positioning, font, transition ... */}
:global([data-theme='dark'] .media-expand-btn) { background: rgba(255, 255, 255, 0.08); border-color: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.6);}
:global(.media-expand-btn:hover) { background: rgba(232, 0, 106, 0.12); border-color: rgba(232, 0, 106, 0.5); color: #e8006a;}Using it in Markdown
Write a fenced code block with the mermaid language identifier. Add a title
attribute in the info string to give screen readers a meaningful label:
```mermaid title="Sequence diagram: user sends a POST request, API returns 202"sequenceDiagram actor User participant API User->>API: POST /request API-->>User: 202 Accepted```That’s all: no shortcodes, no MDX components, no front matter flags. An interactive, themeable diagram with an expand button appears in place of the code block.
The title is optional. Diagrams without one still render correctly and receive
role="img" with a generic aria-label="Diagram" as a baseline fallback.
One caveat worth knowing: the look: 'handDrawn' option only applies to diagram
types that use Mermaid’s shared node/edge renderer: flowcharts, class diagrams, ER
diagrams, and state diagrams. Sequence diagrams have their own rendering path and
render in the classic style regardless. The same flow expressed as a flowchart picks
up the hand-drawn treatment:
What this doesn’t handle
JavaScript-disabled environments. The CSS-animated skeleton is visible from first paint, but if JavaScript is disabled entirely, the skeleton stays in place and no diagram SVG is ever rendered. If that matters, the rehype plugin approach is the right choice.
OG images and static crawlers. Diagrams don’t appear in generated social preview images or in crawlers that don’t execute JavaScript. Those environments receive the skeleton HTML.
Wrap-up
The full implementation, including the remark plugin, the client script, and the skeleton CSS, lives in the site repository. If you’re adding Mermaid to your own Astro project, the remark plugin and the CDN loading pattern are the two pieces most worth adapting: they sidestep the headless browser dependency and keep the build simple.
Working on something similar?
If you’re building a content site or developer blog and want help with the implementation details, I’m available for consulting. Get in touch via the contact page and tell me what you’re working on.
Working on something similar?
Need help raising the bar?
I help teams improve engineering practice through hands-on delivery, pragmatic reviews, and mentoring. If you want a second pair of eyes or practical support, let's talk.
- Engineering practice review
- Hands-on delivery
- Team mentoring
If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.
Free · Practical · One email per post
Get practical engineering notes
One short email when a new article goes live. Useful if you are breaking into tech, growing as an engineer, or improving engineering practice on your team.
Comments
Loading comments…
Leave a comment