Fixing Mermaid Diagram Rendering in Astro: A UX-First Approach
On This Page
Fixing Mermaid Diagram Rendering in Astro: A UX-First Approach
Technical documentation needs diagrams. When you’re explaining cloud architectures, CI/CD pipelines, or system flows, a visual representation is worth a thousand words. I chose Mermaid.js for my portfolio because it’s code-based, version-controllable, and renders beautiful diagrams from simple text syntax.
But getting Mermaid diagrams to work properly in Astro and making them genuinely useful for readers—turned into a multi-day journey through rendering issues, theme conflicts, and UX challenges. Here’s how I solved each problem while keeping user experience as the North Star.
The Problem: Diagrams That Don’t Work in Production
My portfolio uses Astro for static site generation with Vercel hosting. I initially tried the astro-diagram package, which uses Puppeteer to render Mermaid diagrams server-side during build.
It worked perfectly in development. The diagrams rendered beautifully, the colors matched my site theme, and everything looked great locally.
Then I deployed to Vercel. The diagrams vanished. Completely missing from every project page.
Why Server-Side Rendering Failed
Vercel’s serverless environment doesn’t support Puppeteer it requires a full browser instance with system dependencies that don’t exist in the deployment environment. The build logs showed cryptic errors about missing Chrome binaries, and after researching, it became clear: server-side diagram rendering doesn’t work in modern serverless deployments.
Lesson 1: If your deployment environment is serverless, client-side rendering is usually the better choice. Don’t fight the platform.
Solution 1: Client-Side Rendering with Mermaid.js
I switched to client-side rendering by importing mermaid.js directly in my Astro layout:
<script>
import mermaid from 'mermaid';
document.addEventListener('DOMContentLoaded', async () => {
mermaid.initialize({
startOnLoad: false,
theme: 'default',
flowchart: {
padding: 20,
nodeSpacing: 50,
rankSpacing: 50,
},
});
// Find all code blocks containing Mermaid syntax
const allPre = document.querySelectorAll('pre');
const mermaidPres = [];
allPre.forEach((pre) => {
const text = pre.textContent || '';
if (text.includes('graph') || text.includes('flowchart')) {
mermaidPres.push(pre);
}
});
// Convert pre blocks to divs for Mermaid
for (const pre of mermaidPres) {
const code = pre.textContent || '';
const div = document.createElement('div');
div.className = 'mermaid';
div.textContent = code;
pre.replaceWith(div);
}
// Render all diagrams
await mermaid.run({ querySelector: '.mermaid' });
});
</script>
This approach:
- Detects code blocks with Mermaid syntax by content (not CSS classes)
- Converts them to
<div class="mermaid">elements - Renders them client-side after the DOM loads
Result: Diagrams now worked in production. Problem solved… or so I thought.
Problem 2: Theme Switching Broke Everything
My portfolio has dark mode. When users toggled between light and dark themes, the Mermaid diagrams didn’t update they stayed in the original theme from page load. Worse, light mode had white text on white backgrounds, making diagrams completely unreadable.
I tried implementing dynamic theme switching:
// Detect theme and re-render diagrams
const isDarkMode = document.documentElement.classList.contains('dark');
mermaid.initialize({
theme: isDarkMode ? 'dark' : 'base',
themeVariables: isDarkMode ? darkTheme : lightTheme,
});
I added a MutationObserver to watch for theme changes and re-render diagrams. But here’s what happened:
Once Mermaid renders a diagram, it replaces the text with SVG. When the theme changed and I tried to re-render, there was no source text left—just an SVG. I needed to store the original Mermaid code in a Map, restore it before re-rendering, then render again with the new theme.
This worked… in theory. In practice, it created edge cases, timing issues, and complexity that felt wrong.
Solution 2: Simplify with a Neutral Theme
Instead of fighting theme switching, I chose a neutral theme that works in both light and dark modes:
mermaid.initialize({
startOnLoad: false,
theme: 'neutral',
themeVariables: {
fontSize: '16px',
fontFamily: '"JetBrains Mono", monospace',
},
flowchart: {
padding: 25,
nodeSpacing: 80,
rankSpacing: 80,
curve: 'basis',
useMaxWidth: true,
htmlLabels: true,
diagramPadding: 30,
},
});
The 'neutral' theme uses colors that provide good contrast in both light and dark backgrounds. It’s not perfect for either mode, but it’s functional in both and that’s what matters for user experience.
Lesson 2: Perfect is the enemy of good. A neutral solution that works everywhere beats a complex solution that works perfectly sometimes.
Problem 3: Complex Diagrams Were Too Small
As I added more detailed architecture diagrams (like the AWS Security Lab project with 15+ components), the diagrams became cramped and hard to read. Users needed a way to zoom in and see details.
I tried medium-zoom, a popular library for image zooming. Installed it, configured it, tested it.
It didn’t work. Clicks did nothing. After debugging, I discovered the issue: medium-zoom is designed for <img> elements, not inline SVG. It simply doesn’t support the way Mermaid renders diagrams.
Solution 3: Build a Custom Zoom Overlay
Instead of finding another library, I built a simple custom solution:
// Enable zoom on rendered diagrams
const svgs = document.querySelectorAll('.mermaid svg');
svgs.forEach(svg => {
svg.style.cursor = 'zoom-in';
svg.addEventListener('click', () => {
// Create fullscreen overlay
const overlay = document.createElement('div');
overlay.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
background: rgba(0, 0, 0, 0.9);
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
cursor: zoom-out;
padding: 48px;
`;
// Clone and display the SVG
const clone = svg.cloneNode(true);
clone.style.cssText = `
max-width: 90vw;
max-height: 90vh;
width: auto;
height: auto;
cursor: zoom-out;
`;
overlay.appendChild(clone);
document.body.appendChild(overlay);
// Close on click or ESC
overlay.addEventListener('click', () => overlay.remove());
const closeOnEsc = (e) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', closeOnEsc);
}
};
document.addEventListener('keydown', closeOnEsc);
});
});
This solution:
- Shows a
zoom-incursor on hover - Clones the SVG on click and displays it in a fullscreen overlay
- Centers the diagram with comfortable padding
- Closes on click or ESC key
- Uses standard DOM APIs (no dependencies)
Total code: ~60 lines. Works perfectly. No external libraries.
Lesson 3: Sometimes the best solution is the one you build yourself. Don’t reach for a library when vanilla JavaScript is simpler.
The UX Mindset: User Experience Over Technical Perfection
Throughout this journey, I kept coming back to one question: What do users actually need?
- They need diagrams that render (client-side solved this)
- They need diagrams that are readable in their preferred theme (neutral theme solved this)
- They need to zoom in on complex architectures (custom overlay solved this)
At each decision point, I chose the solution that best served user experience, even when it meant abandoning “technically superior” approaches. Dynamic theme switching was cool in theory, but a neutral theme that just works was better in practice.
Key Takeaways
-
Fight the right battles: Server-side rendering with Puppeteer was fighting against the deployment platform. Client-side rendering aligned with it.
-
Simplicity scales: The complex theme-switching solution had edge cases and timing issues. The simple neutral theme approach has zero issues.
-
Build when necessary: When
medium-zoomdidn’t work with SVG, I could have searched for another library or tried to patch it. Building a custom solution took 30 minutes and works exactly how I need it to. -
UX over perfection: Perfect theme integration that sometimes breaks is worse than good-enough neutral colors that always work.
-
Ship and iterate: I deployed with the neutral theme first, then added zoom functionality in a follow-up. Each deployment improved the experience without blocking the previous one.
Resources
- Mermaid.js Documentation
- Astro Client-Side Scripts
- My Portfolio Source (see
src/layouts/Project.astrofor full implementation)
Building user-focused products means making technical trade-offs that prioritize experience over elegance. Sometimes the best code is the code that gets out of the user’s way.