One-off tip

Support checkout

  1. 1 Choose amount
  2. 2 Payment
  3. 3 Thank you

Choose amount

Pick the level of support that feels right. You can keep it simple or enter a custom amount, then continue to secure payment.

Choose a one-off amount
Adding Mermaid diagram support to an Astro blog
Cover image: A person drawing a diagram on a piece of paper — Photo by Kelly Sikkema on Unsplash
astro engineering frontend

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
SeriesPart of How this blog was built — documenting every decision that shaped this site.

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:

src/plugins/remark-mermaid.js
function escapeAttr(str) {
return str
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
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:

astro.config.mjs
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

Terminal window
pnpm add mermaid

In 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
Get guidance

If this has been useful, you can back the writing with a one-off tip through a secure Stripe checkout.

Comments

Loading comments…

Leave a comment

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.