Client-Side Mermaid Diagrams With No Build Step
Diagrams are one of those things that always feel like too much effort in a blog post. You either open a GUI tool, export an image, and hope you never need to change it — or you skip the diagram entirely and write three paragraphs that a picture would have replaced.
Mermaid solves this by letting you write diagrams as text, using a simple syntax inside a fenced code block. This post covers how I added Mermaid support to this blog and what it looks like in practice.
How It Works
This blog builds static HTML from Markdown using a custom Node.js script. The build process uses marked for Markdown parsing, with a custom code renderer for syntax highlighting via highlight.js.
Adding Mermaid was a matter of intercepting mermaid language code blocks before they reach highlight.js:
code({ text, lang }) {
if (lang === 'mermaid') {
return `<div class="mermaid-wrap"><pre class="mermaid">${escapeHtml(text)}</pre></div>`;
}
// ... existing highlight.js path
}The .mermaid-wrap div handles mobile scrolling and visual presentation. The <pre class="mermaid"> is what Mermaid.js looks for when it runs in the browser.
The Mermaid library is only loaded on pages that actually contain diagrams, by checking the rendered HTML before writing the page:
extraHead: getHighlightExtraHead()
+ (html.includes('class="mermaid"') ? getMermaidHead() : '')
+ jsonLdScript,The getMermaidHead function injects a module script that picks up the current light/dark theme:
function getMermaidHead() {
return `
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs';
mermaid.initialize({
startOnLoad: true,
theme: document.documentElement.getAttribute('data-theme') === 'light'
? 'default'
: 'dark',
});
</script>`;
}Because it's a <script type="module">, it defers automatically — the DOM is fully parsed before Mermaid runs, so all .mermaid elements are already present.
Examples
Flowchart
Flowcharts are the most common use case. Here's a CI/CD deployment pipeline:
graph TD
A[Push to main] --> B[Run Tests]
B -->|pass| C[Build Docker Image]
B -->|fail| D[Notify Developer]
C --> E[Push to Registry]
E --> F[Deploy to Staging]
F --> G{Manual Approval}
G -->|approved| H[Deploy to Production]
G -->|rejected| I[Rollback]graph TD
A[Push to main] --> B[Run Tests]
B -->|pass| C[Build Docker Image]
B -->|fail| D[Notify Developer]
C --> E[Push to Registry]
E --> F[Deploy to Staging]
F --> G{Manual Approval}
G -->|approved| H[Deploy to Production]
G -->|rejected| I[Rollback]The syntax is straightforward: graph TD for top-down layout, --> for arrows, |label| for edge labels, and bracket shapes ([], {}, ()) for different node styles.
Sequence Diagram
Sequence diagrams are great for documenting API flows or request lifecycles:
sequenceDiagram
participant Browser
participant CDN
participant Server
Browser->>CDN: GET /page
CDN-->>Browser: Cache miss
CDN->>Server: Forward request
Server-->>CDN: 200 OK + content
CDN-->>Browser: Response + cache headers
Browser->>CDN: GET /page (repeat)
CDN-->>Browser: 304 Not ModifiedsequenceDiagram
participant Browser
participant CDN
participant Server
Browser->>CDN: GET /page
CDN-->>Browser: Cache miss
CDN->>Server: Forward request
Server-->>CDN: 200 OK + content
CDN-->>Browser: Response + cache headers
Browser->>CDN: GET /page (repeat)
CDN-->>Browser: 304 Not Modified->> is a solid arrow, -->> is a dashed return arrow. Participants are declared up front or inferred from the first message they appear in.
Git Graph
For posts about branching strategies or release workflows:
gitGraph
commit id: "Initial commit"
branch feature/auth
checkout feature/auth
commit id: "Add login endpoint"
commit id: "Add JWT middleware"
checkout main
merge feature/auth
branch release/1.0
checkout release/1.0
commit id: "Bump version"
checkout main
merge release/1.0 tag: "v1.0"gitGraph
commit id: "Initial commit"
branch feature/auth
checkout feature/auth
commit id: "Add login endpoint"
commit id: "Add JWT middleware"
checkout main
merge feature/auth
branch release/1.0
checkout release/1.0
commit id: "Bump version"
checkout main
merge release/1.0 tag: "v1.0"Source Toggle and Copy
Each diagram has a toolbar in the top-right corner with three buttons: zoom, source, and copy.
Clicking zoom opens the diagram in a full-screen modal. From there you can scroll to zoom in and out, and drag to pan around — useful for large diagrams where the inline view is too small to read comfortably. Press Escape or click outside the diagram to close.
Clicking source swaps the rendered diagram for the Mermaid code that produced it. Clicking diagram switches back. When source view is active, the copy button becomes visible — clicking it copies the diagram source to the clipboard, useful for adapting a diagram in the Mermaid live editor or pasting into another post. The zoom button is hidden in source view since there is nothing to pan around.
Both buttons are wired up alongside Mermaid initialisation:
document.querySelectorAll('.mermaid-toggle').forEach(function(btn) {
btn.addEventListener('click', function() {
var wrap = btn.closest('.mermaid-wrap');
var diagram = wrap.querySelector('.mermaid-diagram');
var source = wrap.querySelector('.mermaid-source');
var copy = wrap.querySelector('.mermaid-copy');
var showing = btn.getAttribute('data-state');
if (showing === 'diagram') {
diagram.hidden = true;
source.hidden = false;
copy.hidden = false;
btn.setAttribute('data-state', 'source');
btn.textContent = 'diagram';
} else {
diagram.hidden = false;
source.hidden = true;
copy.hidden = true;
btn.setAttribute('data-state', 'diagram');
btn.textContent = 'source';
}
});
});
document.querySelectorAll('.mermaid-copy').forEach(function(btn) {
btn.addEventListener('click', function() {
var code = btn.closest('.mermaid-wrap').querySelector('.mermaid-source code');
navigator.clipboard.writeText(code.textContent).then(function() {
btn.textContent = 'copied!';
btn.classList.add('copied');
setTimeout(function() {
btn.textContent = 'copy';
btn.classList.remove('copied');
}, 2000);
});
});
});The toggle label always reflects what clicking will show next. The copy button is hidden by default and only appears in source view, so it's never shown alongside the rendered diagram.
The zoom modal is created once and appended to <body> after Mermaid initialises. Clicking the zoom button clones the rendered SVG into the modal stage — cloning rather than moving it means the inline diagram stays intact when the modal is closed.
document.querySelectorAll('.mermaid-zoom').forEach(function(btn) {
btn.addEventListener('click', function() {
var svg = btn.closest('.mermaid-wrap').querySelector('.mermaid-diagram svg');
if (svg) openModal(svg);
});
});
function openModal(svg) {
scale = 1; tx = 0; ty = 0;
stage.innerHTML = '';
var clone = svg.cloneNode(true);
clone.removeAttribute('width');
clone.removeAttribute('height');
clone.style.cursor = 'grab';
stage.appendChild(clone);
modal.hidden = false;
document.body.style.overflow = 'hidden';
}Pan and zoom are applied as a CSS transform directly on the SVG element. Scroll events adjust scale, drag events adjust tx/ty, and both call applyTransform:
function applyTransform() {
stage.querySelector('svg').style.transform =
'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
}
stage.addEventListener('wheel', function(e) {
e.preventDefault();
var factor = e.deltaY < 0 ? 1.12 : 1 / 1.12;
scale = Math.max(0.1, Math.min(20, scale * factor));
applyTransform();
}, { passive: false });Transforming the SVG directly rather than its container keeps the math simple — the origin stays at the centre of the stage and zoom feels natural around the cursor position.
Each diagram's HTML is structured as a toolbar plus two content areas — one for the rendered diagram, one for the raw source:
<div class="mermaid-wrap">
<div class="mermaid-toolbar">
<button class="mermaid-copy" hidden>copy</button>
<button class="mermaid-zoom">zoom</button>
<button class="mermaid-toggle" data-state="diagram">source</button>
</div>
<div class="mermaid-diagram">
<pre class="mermaid">graph TD ...</pre>
</div>
<div class="mermaid-source" hidden>
<pre><code>graph TD ...</code></pre>
</div>
</div>Both the diagram and source are generated at build time from the same fenced code block — no duplication needed in the Markdown.
Mobile Considerations
Mermaid renders diagrams as inline SVG, which scales cleanly but doesn't reflow. On narrow screens, complex diagrams can be wider than the viewport.
The .mermaid-wrap container handles this with overflow-x: auto, so diagrams scroll horizontally rather than overflowing or rendering unreadably small:
.mermaid-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
margin: 1.5rem 0;
border: 1px solid var(--border-lit);
border-radius: 8px;
background: var(--surface);
padding: 1.5rem;
text-align: center;
}
.mermaid-wrap svg {
display: inline-block;
max-width: 100%;
height: auto;
}Simple diagrams fit within the content width and render without scrolling. Complex ones scroll rather than break the layout.
Writing Mermaid in Markdown
To include a diagram in a post, use a fenced code block with mermaid as the language:
```mermaid
graph TD
A[Start] --> B[End]
```The Mermaid live editor is useful for iterating on diagram syntax before pasting into a post — it gives immediate visual feedback and highlights parse errors.
Comments