Three Architectures, One Solution: Building a Mermaid.js Diagram System

- Published on
- /39 mins read
Three weeks. Three architectures. 706 lines of JavaScript deleted. Sometimes the best engineering decision is admitting you over-engineered.
📢 This Post Has Been Split Into a Series
TL;DR: Three approaches to Mermaid diagrams in Next.js: build-time SVG (45s builds, abandoned), interactive pan/zoom (706 lines, over-engineered), and simple CSS (3 lines, shipped). The lesson: simple solutions that work beat complex solutions that impress.
This comprehensive journey has been reorganized into three focused posts for better readability and engagement:
Part 1: When Build-Time Rendering Seemed Like a Good Idea →
Learn why Puppeteer and headless Chrome aren't always the answer, and how 9x slower builds taught me about premature optimization. ~15 min read
Part 2: Building Interactive Pan/Zoom Controls I Didn't Need →
706 lines of smooth momentum scrolling, physics simulation, and SVG rendering bugs—all for a feature nobody requested. ~30 min read
Part 3: Simple CSS Fixed What 308 Lines of JavaScript Couldn't →
The embarrassingly simple solution that shipped, complete with implementation guide and lessons about deleting code confidently. ~20 min read
Why split? The original 1,753-line post (50+ min read) was comprehensive but overwhelming. This series format makes it easier to digest, share, and reference specific learnings.
Prefer the full version? The complete content is preserved below for those who want the entire journey in one place.
Introduction: A Journey From Complexity to Simplicity
📦 Mermaid Version Note: This tutorial uses Mermaid v10.9.1. The code examples and API calls shown are compatible with v10.x.
For Mermaid v11.x users: Breaking API change in v11.0.0+
- Replace
mermaid.initialize()withawait mermaid.run()- See the official migration guide for full details
- Bundle size increased from ~373 KB (v10.x) to ~480 KB (v11.x)
Started a project thinking "this will be simple," only to find yourself knee-deep in complexity weeks later? That's what happened when I decided to add diagram support to my blog.
I'm building a technical blog using Next.js and MDX (Markdown with React components). I wanted to write diagrams directly in my markdown files using Mermaid.js, a popular diagramming library. What seemed like a weekend project turned into an exploration through three completely different architectures, with valuable lessons about engineering trade-offs along the way.
This journey covers three attempts:
- Attempt 1: Server-side SVG generation (spoiler: hit a wall)
- Attempt 2: Interactive pan/zoom controls (over-engineered)
- Final solution: Simple client-side rendering (perfect)
Understanding the Problem
What is Mermaid.js?
Mermaid.js is a JavaScript library that turns text into diagrams. Instead of using design tools, you write code like this:
And it renders as an interactive SVG diagram. Perfect for technical documentation!
The Goal
As a content author, I want to write this in my MDX files:
Here's how our system works:
\`\`\`mermaid
graph TD
A[Start] --> B[Process]
B --> C[End]
\`\`\`And have it automatically render as a beautiful diagram when readers visit the page.
Technical Requirements
Let me break down what "working correctly" means:
- Author-friendly: I should write standard markdown, not HTML or complex syntax
- Theme-aware: Diagrams should automatically match light/dark mode
- High-quality rendering: SVG should be crisp, not pixelated, at all zoom levels
- Performant: Fast page loads, minimal JavaScript bundle size
- Simple maintenance: Less code = fewer bugs = easier future changes
Now, with these requirements clear, here's the first approach I tried.
Architecture 1: Build-Time SVG Generation (The Ambitious Approach)
The Idea: Why Not Generate SVGs During Build?
When I started researching, I thought: "Why send Mermaid.js (373 KB) to every visitor? Why not generate diagrams once during build and ship static SVG?"
This is called build-time rendering or static site generation (SSG). The concept is simple:
Traditional approach (what most people do):
1. User visits page
2. Browser downloads Mermaid.js (373 KB)
3. Browser parses diagram code
4. Browser renders SVG
5. User sees diagram (slow)
My idea (build-time):
1. During `npm run build`, generate SVG
2. Inject SVG directly into HTML
3. User visits page
4. SVG is already there (instant!)
Expected benefits:
- ⚡ Instant rendering - No client-side JavaScript needed
- 📦 Smaller bundles - Don't ship 373 KB Mermaid library
- 🚀 Better SEO - Search engines see SVG immediately
- 🎨 Consistency - Same SVG every time, no rendering bugs
Sounds perfect, right? Let's see how I tried to build it.
Understanding the Build Pipeline
To implement this, I needed to hook into Next.js's build process. Here's how Next.js transforms markdown into HTML:
What are Remark and Rehype?
- Remark processes markdown → transforms syntax (this is where we'd intercept Mermaid blocks)
- Rehype processes HTML → adds syntax highlighting, etc.
My plan was to create a custom Remark plugin that:
- Finds all code blocks with
lang="mermaid" - Runs Mermaid.js to generate SVG
- Replaces the code block with the SVG in the AST (Abstract Syntax Tree)
Step 1: Creating the Remark Plugin
Here's the code I wrote:
import { visit } from 'unist-util-visit'
import mermaid from 'mermaid'
export function remarkMermaidBuildTime() {
return async (tree: any) => {
const codeBlocks: any[] = []
// Step 1: Walk through the markdown AST (Abstract Syntax Tree)
// and collect all code blocks with lang="mermaid"
visit(tree, 'code', (node, index, parent) => {
if (node.lang === 'mermaid') {
// Store references so we can modify them later
codeBlocks.push({ node, index, parent })
}
})
// Step 2: Generate SVG for each Mermaid block
for (const { node, index, parent } of codeBlocks) {
// node.value contains the diagram code like "graph TD\n A-->B"
const svg = await mermaid.render(`mermaid-${index}`, node.value)
// Step 3: Replace the code block node with raw HTML containing SVG
parent.children[index] = {
type: 'html', // Tell the parser this is HTML, not markdown
value: svg // The actual SVG string
}
}
}
}What this code does:
visit(tree, 'code', ...)- This walks through every code block in the markdownif (node.lang === 'mermaid')- Checks if it's a Mermaid diagram (not JavaScript, Python, etc.)mermaid.render(...)- Calls Mermaid.js to generate SVG from the text- Replace node - Swaps the code block with the SVG
Seems straightforward! But there's a problem we'll discover soon.
Step 2: Solving the Theme Problem
Before we continue, there's a tricky challenge: how do we support light/dark mode with static SVGs?
During build, we don't know which theme the user will choose. The build runs once, but users can toggle themes. Here were my options:
Option A: Generate both light and dark SVGs
// Generate TWO versions of each diagram
const lightSvg = await mermaid.render(id, chart, {
theme: 'default' // Light theme
})
const darkSvg = await mermaid.render(id, chart, {
theme: 'dark' // Dark theme
})
// Wrap with conditional CSS classes
const html = `
<div class="light:block dark:hidden">${lightSvg}</div>
<div class="light:hidden dark:block">${darkSvg}</div>
`Pros: Works perfectly Cons: Doubles the HTML size, generates each diagram twice
Option B: Use CSS variables (smarter)
const svg = await mermaid.render(id, chart, {
theme: 'base', // Neutral base theme
themeVariables: {
primaryColor: 'var(--diagram-primary)', // CSS variable
secondaryColor: 'var(--diagram-secondary)',
// These CSS variables change when user toggles theme
}
})Pros: One SVG, adapts automatically Cons: Requires careful CSS setup
I was leaning toward Option B when I hit a much bigger problem...
The Reality Check: When Node.js Isn't a Browser
I added my plugin to contentlayer.config.ts:
import { remarkMermaidBuildTime } from './utils/remark-mermaid-build-time'
export default makeSource({
mdx: {
remarkPlugins: [
remarkMermaidBuildTime, // Our custom plugin
// ... other plugins
],
},
})Then ran the build:
$ npm run buildAnd immediately got an error:
Error: document is not defined
at node_modules/mermaid/dist/mermaid.core.mjs:47423:15What happened?
Mermaid.js uses browser-only APIs like document, window, and the DOM. These don't exist in Node.js (the build environment). The library was literally trying to call document.createElement() during the build, which crashed.
Why this happens:
- Browser environment: Has
window,document, DOM APIs - Node.js environment (build): No browser APIs, just server-side JavaScript
Mermaid was built for browsers, not servers.
Attempt #1.5: Using Puppeteer (The Headless Browser Approach)
I found @mermaid-js/mermaid-cli, which wraps Mermaid in Puppeteer (a headless Chrome browser). This would let me run browser code during builds:
npm install @mermaid-js/mermaid-cliimport { run } from '@mermaid-js/mermaid-cli'
// Generate diagram by launching headless Chrome
await run(
'input.mmd', // Input file with Mermaid code
'output.svg', // Output SVG
{ parseMMDOptions: { theme: 'default' } }
)This technically works, but...
New problems:
- Puppeteer adds 280 MB to
node_modules(it bundles Chromium) - Build time explodes - Launching Chrome for each diagram takes seconds
- CI/CD nightmare - GitHub Actions, Docker need Chrome installed
- Development friction - Every markdown change triggers slow rebuild
For a blog with 10 posts × 3 diagrams each = 30 Chrome launches per build. My local builds went from 5 seconds to 45 seconds.
Architecture 1: Final Verdict
Let me weigh the trade-offs honestly:
| Aspect | Score | Reality |
|---|---|---|
| Runtime Performance | 🟢 Great | Zero JavaScript shipped to users |
| Bundle Size | 🟢 Great | No 373 KB Mermaid library |
| Build Performance | 🔴 Terrible | 45 second builds (was 5 seconds) |
| Developer Experience | 🔴 Terrible | Slow hot-reload, painful development |
| Maintenance | 🔴 Terrible | 280 MB Puppeteer dependency |
| Complexity | 🔴 High | Headless browser, CLI integration |
| Theme Support | 🟡 Hacky | Need workarounds for light/dark |
Decision: ❌ Abandoned
Why I gave up:
The theoretical benefits (faster page loads, smaller bundles) weren't worth the practical costs:
- My blog has ~5-10 diagrams per post, not hundreds
- A 373 KB library, code-split, loads in ~200ms on 3G
- But my development experience became horrible
- Build times went from 5s → 45s (9x slower!)
Key lesson learned: Don't optimize for problems you don't have. I was solving for "millions of diagrams" when I had dozens. This is called premature optimization, and it's a trap.
Let's try a different approach.
Architecture 2: Interactive Client-Side (The Over-Engineering Phase)
The Pivot: Embracing Client-Side Rendering
After abandoning build-time rendering, I went back to basics. The existing client-side Mermaid implementation worked perfectly:
- User visits page with blog post
- Browser downloads Mermaid.js (~373 KB, code-split)
- Mermaid parses diagram syntax
- SVG renders in browser
- Done!
Simple. Reliable. Boring.
But then I had an idea...
"If we're already running JavaScript in the browser, why not make diagrams interactive? Add pan and zoom controls like Google Maps! That would be so much better for readers!"
This is the moment where I went from solving a problem to adding features nobody asked for. Looking back, this was over-engineering in action. But let's walk through it—there are valuable lessons in the journey.
My new vision:
- ✅ Render Mermaid.js client-side (keep what works)
- ✨ Add pan/zoom for large diagrams (sounds useful!)
- ✨ Momentum scrolling (like Google Maps—fancy!)
- ✨ Keyboard shortcuts (+ / - to zoom—power users!)
- ✨ Hover-only controls (clean UI—designer-approved!)
- ✨ Touch gestures (pinch-to-zoom—mobile-first!)
Reading that list now, I can see the problem: I was building features because they sounded cool, not because users needed them. But I didn't realize it yet.
Understanding the Component Architecture
Before diving into code, let me explain how React components work together. If you're new to React, think of components like LEGO blocks that snap together to build complex UIs.
What we're building:
Component hierarchy (bottom-up):
<MermaidInteractive enableControls={true}> ← User sees this in markdown
↓
<MermaidWithControls> ← Wrapper with pan/zoom logic
↓
<usePanZoom hook> ← 308 lines of state management
↓
<DiagramControls> ← 322 lines of UI buttons
↓
<Mermaid> ← Base component (renders SVG)
Each layer adds functionality, but also complexity. Let's build this from the ground up.
Step 1: The Basic Mermaid Component (Foundation)
First, I needed a solid base component that renders Mermaid diagrams with theme support:
'use client'
import mermaid from 'mermaid'
import { useTheme } from 'next-themes'
import { useEffect, useRef } from 'react'
// Initialize Mermaid once when module loads
mermaid.initialize({
startOnLoad: false, // We'll manually trigger rendering
theme: 'default', // Default light theme
})
interface MermaidProps {
chart: string // The diagram code like "graph TD\n A-->B"
}
export function Mermaid({ chart }: MermaidProps) {
// Refs: Direct access to DOM elements (bypassing React's virtual DOM)
const containerRef = useRef<HTMLDivElement>(null)
// Get current theme (light/dark) from context
const { theme } = useTheme()
// Re-render diagram when chart code or theme changes
useEffect(() => {
if (!containerRef.current) return
// Step 1: Clear previous diagram
containerRef.current.innerHTML = ''
// Step 2: Update Mermaid theme to match user preference
mermaid.initialize({
theme: theme === 'dark' ? 'dark' : 'default',
})
// Step 3: Generate unique ID for this diagram
// (Mermaid needs unique IDs to track multiple diagrams on one page)
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`
// Step 4: Render the diagram
mermaid
.render(id, chart)
.then(({ svg }) => {
// Inject SVG into our container
if (containerRef.current) {
containerRef.current.innerHTML = svg
}
})
.catch((error) => {
console.error('Mermaid rendering failed:', error)
// Show error to user instead of blank space
if (containerRef.current) {
containerRef.current.innerHTML = `
<div style="color: red; border: 1px solid red; padding: 1rem;">
Failed to render diagram: ${error.message}
</div>
`
}
})
}, [chart, theme]) // Dependencies: re-run when these change
return (
<div
ref={containerRef}
className="mermaid-container my-8"
// Accessible: screen readers announce this is a diagram
role="img"
aria-label="Mermaid diagram"
/>
)
}What this code does:
useRef- Creates a reference to the actual DOM element (not React's virtual DOM)useTheme- Subscribes to theme changes (light ↔ dark mode toggle)useEffect- Runs side effects (like rendering) when dependencies changemermaid.render()- Converts text → SVG asynchronously- Error handling - Shows helpful message if diagram code is invalid
Teaching moment: Why useEffect?
React's rendering is declarative: you describe what should be shown, not how to show it. But Mermaid.js is imperative: you must tell it to render at specific times.
useEffect bridges this gap: "After React renders this component, then run Mermaid."
This base component worked perfectly. But instead of stopping here, I thought: "Let's make it interactive!"
Step 2: Building the Pan/Zoom Hook (The Complex Part)
Custom React hooks encapsulate stateful logic that multiple components can reuse. I created usePanZoom to handle:
- Mouse wheel zoom (zoom toward cursor position)
- Click-and-drag panning (move diagram around)
- Momentum scrolling (continues moving after mouse release)
- Keyboard shortcuts (+ / - / R / F keys)
- Programmatic controls (zoom in/out buttons)
Here's the complete implementation with detailed explanations:
import { useCallback, useEffect, useRef, useState, RefObject } from 'react'
// Configuration options for pan/zoom behavior
interface PanZoomOptions {
minScale: number // Don't zoom out beyond this (e.g., 0.5 = 50%)
maxScale: number // Don't zoom in beyond this (e.g., 3 = 300%)
wheelSensitivity: number // How fast wheel zooms (0.01 = 1% per tick)
zoomStep: number // How much +/- buttons zoom (0.2 = 20%)
momentumDamping: number // Friction (0.92 = lose 8% speed per frame)
}
// Return type: functions caller can use
interface PanZoomControls {
transform: { x: number; y: number; scale: number }
zoomIn: () => void
zoomOut: () => void
reset: () => void
fitToView: () => void
isPanning: boolean
}
export function usePanZoom(
containerRef: RefObject<HTMLDivElement>, // The viewport (fixed size)
contentRef: RefObject<HTMLDivElement>, // The content (can be larger)
options: PanZoomOptions
): PanZoomControls {
// State: Current position and zoom level
const [transform, setTransform] = useState({
x: 0, // Pan offset X (pixels)
y: 0, // Pan offset Y (pixels)
scale: 1, // Zoom level (1 = 100%, 2 = 200%, etc.)
})
// State: Are we currently dragging?
const [isPanning, setIsPanning] = useState(false)
// State: Momentum velocity (for inertia after drag release)
const [momentum, setMomentum] = useState({
vx: 0, // Velocity X (pixels per millisecond)
vy: 0, // Velocity Y (pixels per millisecond)
})
// Refs: Store mutable values that don't trigger re-renders
const dragStartRef = useRef({ x: 0, y: 0 })
const lastPosRef = useRef({ x: 0, y: 0, time: 0 })
/**
* Mouse Wheel Handler: Zoom toward cursor position
*
* The math here is tricky! When zooming, we want the point under
* the cursor to stay fixed. This requires adjusting both scale AND pan.
*/
const handleWheel = useCallback((e: WheelEvent) => {
e.preventDefault()
// Get viewport boundaries
const rect = containerRef.current?.getBoundingClientRect()
if (!rect) return
// Calculate cursor position in viewport coordinates
const cursorX = e.clientX - rect.left
const cursorY = e.clientY - rect.top
// Calculate zoom delta (negative deltaY = zoom in)
const delta = -e.deltaY * options.wheelSensitivity
// Calculate new scale (clamped to min/max)
const newScale = Math.max(
options.minScale,
Math.min(options.maxScale, transform.scale * (1 + delta))
)
// How much did scale change? (e.g., 1.1x or 0.9x)
const scaleChange = newScale / transform.scale
/**
* Math explanation:
*
* Before zoom:
* cursorX = transform.x + (contentX * transform.scale)
*
* After zoom (keeping contentX fixed):
* cursorX = newTransform.x + (contentX * newScale)
*
* Solving for newTransform.x:
* newTransform.x = cursorX - (cursorX - transform.x) * scaleChange
*/
setTransform({
x: cursorX - (cursorX - transform.x) * scaleChange,
y: cursorY - (cursorY - transform.y) * scaleChange,
scale: newScale,
})
}, [transform, options, containerRef])
/**
* Mouse Down Handler: Start dragging
*
* This sets up event listeners for mouse movement and release.
* We track velocity for momentum scrolling.
*/
const handleMouseDown = useCallback((e: MouseEvent) => {
// Ignore if not left mouse button
if (e.button !== 0) return
setIsPanning(true)
// Remember where drag started (accounting for current pan)
dragStartRef.current = {
x: e.clientX - transform.x,
y: e.clientY - transform.y,
}
// Initialize velocity tracking
lastPosRef.current = {
x: e.clientX,
y: e.clientY,
time: Date.now(),
}
/**
* Mouse Move Handler: Update position while dragging
*/
const handleMouseMove = (e: MouseEvent) => {
const now = Date.now()
const dt = now - lastPosRef.current.time
// Calculate how far mouse moved
const dx = e.clientX - lastPosRef.current.x
const dy = e.clientY - lastPosRef.current.y
// Update transform position
setTransform({
...transform,
x: e.clientX - dragStartRef.current.x,
y: e.clientY - dragStartRef.current.y,
})
// Calculate velocity for momentum (pixels per millisecond)
if (dt > 0) {
setMomentum({
vx: dx / dt,
vy: dy / dt,
})
}
// Update tracking
lastPosRef.current = { x: e.clientX, y: e.clientY, time: now }
}
/**
* Mouse Up Handler: Stop dragging
*/
const handleMouseUp = () => {
setIsPanning(false)
// Remove event listeners
document.removeEventListener('mousemove', handleMouseMove)
document.removeEventListener('mouseup', handleMouseUp)
}
// Attach listeners to document (so dragging outside viewport works)
document.addEventListener('mousemove', handleMouseMove)
document.addEventListener('mouseup', handleMouseUp)
}, [transform])
/**
* Programmatic zoom functions (for buttons and keyboard)
*/
const zoomIn = useCallback(() => {
setTransform((t) => ({
...t,
scale: Math.min(options.maxScale, t.scale * (1 + options.zoomStep)),
}))
}, [options])
const zoomOut = useCallback(() => {
setTransform((t) => ({
...t,
scale: Math.max(options.minScale, t.scale * (1 - options.zoomStep)),
}))
}, [options])
const reset = useCallback(() => {
setTransform({ x: 0, y: 0, scale: 1 })
setMomentum({ vx: 0, vy: 0 })
}, [])
const fitToView = useCallback(() => {
if (!containerRef.current || !contentRef.current) return
const containerRect = containerRef.current.getBoundingClientRect()
const contentRect = contentRef.current.getBoundingClientRect()
// Calculate scale to fit content in viewport (with 10% padding)
const scaleX = (containerRect.width * 0.9) / contentRect.width
const scaleY = (containerRect.height * 0.9) / contentRect.height
const scale = Math.min(scaleX, scaleY, options.maxScale)
// Center content
const x = (containerRect.width - contentRect.width * scale) / 2
const y = (containerRect.height - contentRect.height * scale) / 2
setTransform({ x, y, scale })
setMomentum({ vx: 0, vy: 0 })
}, [containerRef, contentRef, options])
/**
* Effect: Attach keyboard shortcuts
*/
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
// Ignore if user is typing in an input
if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) {
return
}
switch (e.key) {
case '+':
case '=':
e.preventDefault()
zoomIn()
break
case '-':
case '_':
e.preventDefault()
zoomOut()
break
case 'r':
case 'R':
e.preventDefault()
reset()
break
case 'f':
case 'F':
e.preventDefault()
fitToView()
break
}
}
window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('keydown', handleKeyDown)
}, [zoomIn, zoomOut, reset, fitToView])
/**
* Effect: Attach wheel handler
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('wheel', handleWheel, { passive: false })
return () => container.removeEventListener('wheel', handleWheel)
}, [containerRef, handleWheel])
/**
* Effect: Attach mouse down handler
*/
useEffect(() => {
const container = containerRef.current
if (!container) return
container.addEventListener('mousedown', handleMouseDown)
return () => container.removeEventListener('mousedown', handleMouseDown)
}, [containerRef, handleMouseDown])
/**
* Effect: Animate momentum (inertia after drag release)
*
* This creates smooth "throw" behavior like scrolling on mobile.
* Uses requestAnimationFrame for smooth 60fps animation.
*/
useEffect(() => {
// Stop if velocity is negligible
if (Math.abs(momentum.vx) < 0.01 && Math.abs(momentum.vy) < 0.01) {
setMomentum({ vx: 0, vy: 0 })
return
}
// Animation loop
const animate = () => {
// Apply velocity to position
setTransform((t) => ({
...t,
x: t.x + momentum.vx * 16, // * 16 ≈ one frame at 60fps
y: t.y + momentum.vy * 16,
}))
// Apply damping (friction)
setMomentum((m) => ({
vx: m.vx * options.momentumDamping,
vy: m.vy * options.momentumDamping,
}))
}
const id = requestAnimationFrame(animate)
// Cleanup: cancel animation if component unmounts
return () => cancelAnimationFrame(id)
}, [momentum, options.momentumDamping])
// Return controls for parent component to use
return {
transform,
zoomIn,
zoomOut,
reset,
fitToView,
isPanning,
}
}Wow. That's 308 lines of code. And we're not done yet!
Teaching moment: The cost of "nice interactions"
Notice how much complexity is hidden in "smooth pan and zoom":
- Physics simulation (momentum, damping)
- Coordinate system math (viewport vs content)
- Event handling (mouse, keyboard, wheel)
- Memory management (cleanup listeners)
- Edge cases (typing in inputs, boundary clamping)
Each feature multiplies complexity. This hook alone is more code than the entire simple solution we'll end up with.
Step 3: The Controls UI (Making It Pretty)
Next, I needed UI buttons for users who don't know keyboard shortcuts:
'use client'
import { useCallback, useState } from 'react'
interface DiagramControlsProps {
onZoomIn: () => void
onZoomOut: () => void
onReset: () => void
onFitToView: () => void
currentScale: number
isPanning: boolean
}
export function DiagramControls({
onZoomIn,
onZoomOut,
onReset,
onFitToView,
currentScale,
isPanning,
}: DiagramControlsProps) {
// State: Show controls only on hover
const [isHovered, setIsHovered] = useState(false)
return (
<div
className="relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{/* Controls panel (hover-only) */}
<div
className={`
absolute top-2 right-2 z-10
flex flex-col gap-2
bg-white dark:bg-gray-800
rounded-lg shadow-lg
p-2
transition-opacity duration-200
${isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'}
`}
>
{/* Zoom In Button */}
<button
onClick={onZoomIn}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Zoom In (+)"
aria-label="Zoom in"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M10 5v10M5 10h10" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Zoom Out Button */}
<button
onClick={onZoomOut}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Zoom Out (-)"
aria-label="Zoom out"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M5 10h10" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Reset Button */}
<button
onClick={onReset}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Reset (R)"
aria-label="Reset zoom and position"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M4 10a6 6 0 1112 0" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Fit to View Button */}
<button
onClick={onFitToView}
className="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded"
title="Fit to View (F)"
aria-label="Fit diagram to viewport"
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<rect x="3" y="3" width="14" height="14" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
{/* Current Zoom Display */}
<div className="text-xs text-center text-gray-600 dark:text-gray-400 mt-2">
{Math.round(currentScale * 100)}%
</div>
</div>
{/* Panning indicator */}
{isPanning && (
<div className="absolute bottom-2 left-2 z-10 px-3 py-1 bg-blue-500 text-white text-sm rounded">
Panning...
</div>
)}
</div>
)
}Features I built:
- ✨ Hover-only UI (clean when not needed)
- ✨ Animated transitions (opacity fade)
- ✨ SVG icons (crisp at any size)
- ✨ Accessibility (aria-labels, keyboard shortcuts shown)
- ✨ Theme-aware colors (light/dark mode)
- ✨ Current zoom percentage display
- ✨ Panning status indicator
This alone was 322 lines of component code, styling, and SVG icons.
Step 4: Connecting Everything (The Wrapper)
Now I needed to connect the hook and controls:
'use client'
import { useRef } from 'react'
import { Mermaid } from './mermaid'
import { DiagramControls } from './diagram-controls'
import { usePanZoom } from '@/hooks/use-pan-zoom'
interface MermaidWithControlsProps {
chart: string
enableControls?: boolean
}
export function MermaidWithControls({
chart,
enableControls = true
}: MermaidWithControlsProps) {
// Refs for pan/zoom
const containerRef = useRef<HTMLDivElement>(null)
const contentRef = useRef<HTMLDivElement>(null)
// Hook up pan/zoom logic
const { transform, zoomIn, zoomOut, reset, fitToView, isPanning } = usePanZoom(
containerRef,
contentRef,
{
minScale: 0.5,
maxScale: 3,
wheelSensitivity: 0.001,
zoomStep: 0.2,
momentumDamping: 0.92,
}
)
return (
<div ref={containerRef} className="relative overflow-hidden border rounded-lg">
{/* Controls (if enabled) */}
{enableControls && (
<DiagramControls
onZoomIn={zoomIn}
onZoomOut={zoomOut}
onReset={reset}
onFitToView={fitToView}
currentScale={transform.scale}
isPanning={isPanning}
/>
)}
{/* Content with transforms applied */}
<div
ref={contentRef}
style={{
transform: `translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`,
transformOrigin: '0 0',
cursor: isPanning ? 'grabbing' : 'grab',
}}
>
<Mermaid chart={chart} />
</div>
</div>
)
}Finally, wrap in a code-split component for performance:
'use client'
import dynamic from 'next/dynamic'
// Code-split: only load when diagram is visible
const MermaidWithControls = dynamic(
() => import('./mermaid-with-controls').then((mod) => mod.MermaidWithControls),
{
ssr: false,
loading: () => <div className="animate-pulse bg-gray-200 h-64 rounded" />,
}
)
export function MermaidInteractive({ chart, enableControls = true }) {
return <MermaidWithControls chart={chart} enableControls={enableControls} />
}The Implementation Summary
Total code written:
usePanZoomhook: 308 linesDiagramControlscomponent: 322 linesMermaidWithControlswrapper: 38 linesMermaidInteractiveclient component: 38 lines- Grand total: 706 lines of code
For a feature that:
- Nobody requested
- Solved a problem users didn't have
- Added 30 KB to bundle size
- Required ongoing maintenance
- Introduced new bugs to fix
But it looked impressive! Smooth animations, momentum scrolling, keyboard shortcuts. I was proud of the engineering.
Then came the reality check...
The Pixelation Problem (When CSS Transforms Go Wrong)
After implementing everything, I tested on desktop. Zoomed in. And discovered: SVG diagrams were pixelated and blurry!
But oddly, they looked crisp on mobile. What was going on?
Root cause: CSS transform: scale() on wrapper div
My code was applying zoom like this:
/* My implementation (wrong!) */
.diagram-container {
transform: scale(1.5);
}The browser's rendering pipeline:
Why this happens:
- Browser renders SVG to bitmap at original size (say, 400×300 pixels)
- CSS
transform: scale(1.5)scales the bitmap (not the vector) - Stretching a 400×300 bitmap to 600×450 = pixelation
Why mobile was fine: Mobile devices have higher pixel density (2x, 3x retina), so the pixelation was less noticeable.
Teaching moment: Understanding browser rendering
SVG is a vector format—mathematical descriptions of shapes. It can scale infinitely without quality loss... if you let it.
But CSS transforms operate on the rendered output (pixels). By the time CSS transforms apply, the SVG has already been converted to a bitmap.
The Solution I Designed (But Never Implemented)
The correct approach: manipulate SVG's native viewBox attribute instead of CSS transforms.
// Instead of CSS transforms, manipulate SVG viewBox (vector-preserving)
const svg = svgRef.current
const viewBox = svg.getAttribute('viewBox') // e.g., "0 0 400 300"
const [x, y, width, height] = viewBox.split(' ').map(Number)
// Zoom by adjusting viewBox dimensions
// Smaller viewBox = more zoomed in (shows less of content)
svg.setAttribute('viewBox', `${x} ${y} ${width / scale} ${height / scale}`)How this works:
viewBox="0 0 400 300"means "show content from (0,0) to (400,300)"viewBox="0 0 200 150"means "show content from (0,0) to (200,150)"- Same viewport size, but showing less content = zoomed in
- Browser re-rasterizes SVG at new scale = crisp
The better approach:
I spent hours researching this solution. Wrote detailed specs. Planned the implementation.
But then I stopped and asked myself: "Do readers actually need to zoom and pan?"
The Moment of Truth (Realizing I Over-Engineered)
I looked at my blog posts:
- Most diagrams fit comfortably on screen
- Readers could use browser zoom (Cmd/Ctrl +/-) if needed
- Nobody had ever requested interactive diagrams
I looked at my codebase:
- 706 lines of complex interaction code
- Custom physics simulation
- Event handling edge cases
- Cross-browser testing burden
- Now SVG rendering bugs to debug
I looked at the user experience:
- Controls were hidden until hover (friction)
- Keyboard shortcuts required learning
- Touch gestures on mobile might conflict with scrolling
The question: Is this worth it?
The honest answer: No.
Architecture 2 Verdict
Let me score this honestly:
| Aspect | Score | Reality Check |
|---|---|---|
| Performance | 🟡 Medium | ~30 KB overhead for pan/zoom code |
| Bundle Size | 🟡 Medium | 373 KB Mermaid + 30 KB controls = 403 KB |
| User Experience | 🟡 Mixed | Smooth controls, but hidden by default |
| Code Complexity | 🔴 Terrible | 706 lines for a nice-to-have feature |
| Maintenance | 🔴 Terrible | Custom physics, event handling, browser bugs |
| Problem Solved | 🔴 Questionable | Solving a problem users didn't have |
Decision: After reviewing the implementation, I chose simplification.
Key learning I had:
Sometimes the best feature is the one you don't build. I was building what seemed cool from an engineering perspective, not what users actually needed.
As I told myself: "It looks great at the moment. I got rid of some code, changed some CSS classes." That should have been a red flag earlier.
The trap I fell into:
I started with a working solution (client-side Mermaid rendering). Then asked "what if we made it better?" instead of "what problems do users actually have?"
This is feature creep—adding features because you can, not because you should.
Let's see how simplification wins.
Architecture 3: Simple Client-Side Solution
The Simplification (Choosing Pragmatism Over Perfection)
After implementing 706 lines of interactive controls, debugging pixelation issues, and spending days on momentum physics, I finally stopped and asked myself the most important question:
"Do readers actually need to pan and zoom diagrams?"
I looked at my blog content:
- 90% of diagrams fit comfortably on screen at 100% zoom
- The few large diagrams were readable with browser native zoom (Cmd/Ctrl +/-)
- Nobody had ever requested interactive controls
- Readers on mobile had pinch-to-zoom built-in
The honest answer: No. This was a solution looking for a problem.
The revelation: I had been optimizing for the engineering challenge (building smooth pan/zoom), not for user value (can readers understand my diagrams?).
This is a common trap: building features because they're technically interesting, not because they solve real problems. As engineers, we love complex solutions. But users love working solutions.
What I Decided to Keep (And What to Delete)
Keep:
- ✅ Client-side Mermaid rendering (works reliably)
- ✅ Code-splitting with dynamic imports (performance win)
- ✅ Theme support (respects user preference)
- ✅ Basic Mermaid component (simple, maintainable)
Delete:
- ❌ Pan/zoom hook (308 lines → 0)
- ❌ Diagram controls UI (322 lines → 0)
- ❌ Wrapper components (76 lines → 0)
- ❌ Complex event handling (click, drag, wheel, keyboard)
- ❌ Physics simulation (momentum, damping)
- ❌ SVG viewBox manipulation plans
Total deletion: 706 lines of code
What I needed to fix: The pixelation issue (which was caused by my pan/zoom implementation).
The solution: Simple CSS rendering hints.
Let me show you how simple this became.
The Final Implementation (From 706 Lines to 31 Lines)
Step 1: Simplify the Remark Plugin
The remark plugin transforms markdown code blocks into React components. My interactive version was 56 lines. The simple version:
import { visit } from 'unist-util-visit'
/**
* Remark plugin to transform Mermaid code blocks into Mermaid components
*
* Transforms:
* ```mermaid
* graph TD
* A --> B
* ```
*
* Into:
* <Mermaid chart="graph TD\n A --> B" />
*/
export function remarkMermaidMdx() {
return (tree: any) => {
// Walk through all nodes in the markdown AST
visit(tree, 'code', (node: any, index: number, parent: any) => {
// Only process mermaid code blocks
if (node.lang !== 'mermaid') return
// Replace code block with MDX component
parent.children[index] = {
type: 'mdxJsxFlowElement', // This is an MDX JSX element
name: 'Mermaid', // Component name
attributes: [
{
type: 'mdxJsxAttribute',
name: 'chart', // Prop name
value: node.value, // The diagram code
},
],
children: [],
}
})
}
}What changed:
- Before: Generated
<MermaidInteractive>with complex AST for props, controls, wrappers - After: Generates simple
<Mermaid chart="..." /> - Reduction: 56 lines → 28 lines (50% smaller)
This is pure markdown transformation. No logic, no state, no complexity. Just: "When you see mermaid code, swap it for a Mermaid component."
Step 2: Fix SVG Rendering Quality with CSS
Remember the pixelation problem? The one I was going to fix with complex SVG viewBox manipulation? Turns out CSS has hints for this:
/* Tell browsers to prioritize quality when rendering SVGs */
.mermaid-container svg {
image-rendering: optimizeQuality; /* Prefer quality over speed */
shape-rendering: geometricPrecision; /* Render shapes precisely */
}What these properties do:
image-rendering: optimizeQuality- Tells browser: "I don't care if rendering is slower, make it look good"shape-rendering: geometricPrecision- Tells browser: "Render curves and lines accurately, not optimized"
The result: Crisp, sharp SVG rendering at all zoom levels. Even when users zoom to 300% with browser zoom, vectors stay sharp.
Note: While some guides suggest text-rendering: geometricPrecision for SVG text, this property doesn't meaningfully affect SVG rendering in modern browsers—text quality is already determined by the vector rendering pipeline.
Two CSS properties fixed what I thought needed 308 lines of JavaScript.
Teaching moment: Understanding how browsers work
Browsers have rendering hints for different scenarios:
optimizeSpeed- Fast rendering, lower quality (for animations, games)optimizeQuality- Slow rendering, high quality (for diagrams, illustrations)auto- Browser decides
By default, browsers optimize for speed (most web content is photos, not diagrams). By explicitly requesting quality, we get sharp vector rendering.
This is called working with the platform—using built-in browser features instead of reimplementing them in JavaScript.
Step 3: Delete All the Pan/Zoom Code
This felt both liberating and painful. I had spent days building this:
# Files deleted
$ rm hooks/use-pan-zoom.ts # 308 lines
$ rm components/mdx/diagram-controls.tsx # 322 lines
$ rm components/mdx/mermaid-with-controls.tsx # 38 lines
$ rm components/mdx/mermaid-interactive-client.tsx # 38 lines
# Total: 706 lines deletedWhat I lost:
- ❌ Smooth momentum scrolling
- ❌ Zoom toward cursor
- ❌ Keyboard shortcuts (+/-/R/F)
- ❌ Hover-only UI controls
- ❌ Touch gesture support
- ❌ Custom physics simulation
- ❌ Hundreds of lines of test surface area
What I gained:
- ✅ Simpler codebase
- ✅ No event handling bugs
- ✅ No cross-browser testing
- ✅ No maintenance burden
- ✅ Faster page loads (30 KB smaller bundle)
- ✅ Browser native zoom (free feature!)
The trade-off: Users can't pan diagrams with mouse drag. They use browser zoom (Cmd/Ctrl +/-) or pinch-to-zoom on mobile.
User impact: Zero complaints. Because the existing solution was already fine.
Step 4: Update Component Registry
Remove the interactive component from MDX exports:
// Before (Architecture 2)
import { MermaidInteractive } from './mermaid-interactive-client'
export const MDX_COMPONENTS = {
pre: Pre,
code: Code,
MermaidInteractive, // Complex wrapper with controls
// ... other components
}
// After (Architecture 3)
// No import needed - Mermaid is auto-imported by contentlayer
// The remark plugin generates <Mermaid>, which MDX resolves automatically
export const MDX_COMPONENTS = {
pre: Pre,
code: Code,
// ... other components
}How this works:
The remark plugin generates <Mermaid chart="..." /> in the MDX. Next.js's MDX loader automatically resolves components from:
- Imported components in the MDX file
- Global MDX component context
- Local component scope
Since we already have Mermaid exported from components/mdx/mermaid.tsx, it "just works."
No wiring needed. No registration. No imports. Just simple, automatic resolution.
The Complete Simple Architecture
Let me show you the entire data flow:
Key characteristics:
- Simple: Linear data flow, no branches
- Fast: Code-split, conditional loading
- Maintainable: 28 lines of transformation logic
- Reliable: No custom event handling, physics, or state
- Accessible: Works with browser zoom, screen readers
Final Code Count
Let me compare all three architectures:
| Architecture | Lines of Code | Complexity | Result |
|---|---|---|---|
| Architecture 1 (Build-time) | ~150 lines + Puppeteer | 🔴 High | ❌ Couldn't implement |
| Architecture 2 (Interactive) | 706 lines | 🔴 Very High | ⚠️ Worked but over-engineered |
| Architecture 3 (Simple) | 31 lines | 🟢 Low | ✅ Shipped to production |
The math:
- Architecture 2 → Architecture 3: Deleted 95.6% of code
- Bundle size reduction: 30 KB (7.7%)
- Maintenance burden: Effectively eliminated
- User complaints: Zero
The lesson: More code doesn't mean better software. Sometimes 31 lines beats 706 lines in every dimension that matters.
Architecture Comparison
Architecture 3 Verdict
| Aspect | Score | Notes |
|---|---|---|
| Performance | 🟢 Great | Code-split, ~373 KB only when needed |
| Bundle Size | 🟢 Great | 706 lines removed (-30 KB) |
| User Experience | 🟢 Great | Native browser zoom works fine |
| Code Complexity | 🟢 Great | 28 lines of remark plugin |
| Maintenance | 🟢 Great | Simple, clear, maintainable |
Decision: ✅ Shipped to production.
Performance Comparison
Bundle Size Analysis
| Architecture | JavaScript | CSS | Total | Notes |
|---|---|---|---|---|
| Build-Time | 0 KB | 0 KB | 0 KB | ❌ Couldn't implement |
| Interactive | 403 KB | 2 KB | 405 KB | 373 KB Mermaid + 30 KB controls |
| Simple | 373 KB | 1 KB | 374 KB | Just Mermaid.js, code-split |
Savings: 31 KB (7.7%) from removing pan/zoom code
Load Time Impact
Measured with Chrome DevTools on cable connection (5 Mbps):
| Architecture | FCP | LCP | TTI | Notes |
|---|---|---|---|---|
| Interactive | 1.2s | 2.8s | 3.1s | Loads controls + Mermaid |
| Simple | 1.2s | 2.6s | 2.9s | Loads just Mermaid |
Improvement: 200ms faster TTI (6.5% faster)
Code Maintainability
| Metric | Interactive | Simple | Change |
|---|---|---|---|
| Total Lines | 706 | 28 | -96% |
| Files | 6 | 2 | -67% |
| Dependencies | React, hooks, refs | Just React | Simpler |
| Test Complexity | High (event handling, physics) | Low (just rendering) | Much easier |
Lessons Learned
Question Your Assumptions
I assumed readers would benefit from interactive diagrams. In reality:
- Most diagrams fit on screen
- Browser native zoom (Cmd +/-) works fine
- Nobody requested interactive controls
Learning: Build for actual user needs, not imagined ones.
Complexity Has Costs
The interactive implementation looked impressive:
- ✨ Smooth momentum scrolling
- 🎯 Zoom toward cursor
- ⌨️ Keyboard shortcuts
- 📱 Touch gesture support
But the costs were real:
- 706 lines of code to maintain
- Custom event handling logic
- Physics simulation (momentum damping)
- Cross-browser testing burden
- SVG pixelation debugging
Learning: Every feature has a carrying cost. Weigh it carefully.
Simple Solutions Often Win
The final solution:
- 2 CSS properties for crisp rendering
- 28 lines of remark plugin
- Zero custom event handling
- Native browser zoom support
It's boring. It's simple. It works perfectly.
Learning: Boring technology is good technology.
Prototype Quickly, Decide Slowly
I didn't regret exploring build-time rendering or interactive controls. The exploration taught me:
- Build-time rendering limitations (DOM dependencies)
- SVG rendering quality issues (CSS transforms)
- Component architecture patterns (remark plugins)
- User needs vs. developer wants
Learning: Prototypes are for learning, not shipping.
Delete Code Confidently
Deleting 706 lines felt wrong at first. That was working code! But keeping it would have been worse:
- Ongoing maintenance burden
- Future developers learning unnecessary complexity
- Bundle size overhead for every visitor
Learning: The best code is code you don't have to maintain.
Implementation Guide
Want to add Mermaid diagrams to your blog? Here's the simple solution:
Step 1: Install Dependencies
npm install mermaidStep 2: Create Mermaid Component
'use client'
import mermaid from 'mermaid'
import { useTheme } from 'next-themes'
import { useEffect, useRef } from 'react'
// Initialize with theme
mermaid.initialize({
startOnLoad: false,
theme: 'default',
})
export function Mermaid({ chart }: { chart: string }) {
const ref = useRef<HTMLDivElement>(null)
const { theme } = useTheme()
useEffect(() => {
if (!ref.current) return
// Clear previous
ref.current.innerHTML = ''
// Update theme
mermaid.initialize({
theme: theme === 'dark' ? 'dark' : 'default',
})
// Render
const id = `mermaid-${Math.random().toString(36).substring(2, 11)}`
mermaid.render(id, chart)
.then(({ svg }) => {
if (ref.current) {
ref.current.innerHTML = svg
}
})
.catch(console.error)
}, [chart, theme])
return <div ref={ref} className="mermaid-container my-8" />
}Step 3: Code-Split for Performance
'use client'
import dynamic from 'next/dynamic'
export const MermaidClient = dynamic(
() => import('./mermaid').then((mod) => ({ default: mod.Mermaid })),
{
ssr: false,
loading: () => <div>Loading diagram...</div>,
}
)Step 4: Add Remark Plugin
import { visit } from 'unist-util-visit'
export function remarkMermaidMdx() {
return (tree: any) => {
visit(tree, 'code', (node: any, index: number, parent: any) => {
if (node.lang !== 'mermaid') return
parent.children[index] = {
type: 'mdxJsxFlowElement',
name: 'Mermaid',
attributes: [
{
type: 'mdxJsxAttribute',
name: 'chart',
value: node.value,
},
],
children: [],
}
})
}
}Step 5: Configure Contentlayer/MDX
import { remarkMermaidMdx } from './utils/remark-mermaid-mdx'
export default makeSource({
mdx: {
remarkPlugins: [
remarkMermaidMdx, // Add this BEFORE rehypePrettyCode
// ... other plugins
],
},
})Step 6: Add CSS Quality Hints
.mermaid-container svg {
image-rendering: optimizeQuality;
shape-rendering: geometricPrecision;
}Step 7: Use in Content
Here's a diagram:
\`\`\`mermaid
graph TD
A[Start] --> B{Decision}
B -->|Yes| C[Do Something]
B -->|No| D[Do Something Else]
C --> E[End]
D --> E
\`\`\`Total implementation: ~100 lines of code for full Mermaid support.
Conclusion
The journey from build-time rendering to interactive controls to a simple solution taught me that engineering is about making informed trade-offs.
What Worked
✅ Client-side rendering - Simple, no build complexity
✅ Code-splitting - Only load Mermaid when needed
✅ CSS quality hints - Crisp rendering at all zoom levels
✅ Native browser zoom - Free feature, zero code
✅ Minimal complexity - 28 lines vs 706 lines
What Didn't
❌ Build-time rendering - DOM dependencies, Puppeteer overhead
❌ Pan/zoom controls - Nice-to-have, not need-to-have
❌ Custom event handling - Complexity without proportional value
❌ SVG transform scaling - Caused pixelation issues
Final Architecture
The numbers:
- 706 lines of code deleted
- 31 KB bundle size reduction
- 0 interactive features added
- 100% better maintainability
Sometimes the best code is the code you delete. Sometimes the best feature is the one you don't build. And sometimes, simple CSS properties solve problems that hundreds of lines of JavaScript cannot.
The pragmatic approach wins.
Sources and References
Project Documentation
- MERMAID_SIMPLIFICATION_SUMMARY.md - Complete change log
- MERMAID_IMPLEMENTATION_GUIDE.md - Basic usage guide
- MERMAID_PAN_ZOOM_SPECIFICATION.md - Interactive controls design (historical)
Official Documentation
- Mermaid.js Documentation - Official Mermaid diagramming library
- Next.js Dynamic Imports - Code-splitting guide
- Puppeteer - Headless Chrome for build-time rendering
Markdown Processing
- Remark Plugins - Markdown transformation ecosystem
- Unified.js - Content processing framework
Have you built something complex, then deleted most of it? Share your pragmatic engineering stories.
On this page
- Part 1: When Build-Time Rendering Seemed Like a Good Idea →
- Part 2: Building Interactive Pan/Zoom Controls I Didn't Need →
- Part 3: Simple CSS Fixed What 308 Lines of JavaScript Couldn't →
- Introduction: A Journey From Complexity to Simplicity
- Understanding the Problem
- What is Mermaid.js?
- The Goal
- Technical Requirements
- Architecture 1: Build-Time SVG Generation (The Ambitious Approach)
- The Idea: Why Not Generate SVGs During Build?
- Understanding the Build Pipeline
- Step 1: Creating the Remark Plugin
- Step 2: Solving the Theme Problem
- The Reality Check: When Node.js Isn't a Browser
- Attempt #1.5: Using Puppeteer (The Headless Browser Approach)
- Architecture 1: Final Verdict
- Architecture 2: Interactive Client-Side (The Over-Engineering Phase)
- The Pivot: Embracing Client-Side Rendering
- Understanding the Component Architecture
- Step 1: The Basic Mermaid Component (Foundation)
- Step 2: Building the Pan/Zoom Hook (The Complex Part)
- Step 3: The Controls UI (Making It Pretty)
- Step 4: Connecting Everything (The Wrapper)
- The Implementation Summary
- The Pixelation Problem (When CSS Transforms Go Wrong)
- The Solution I Designed (But Never Implemented)
- The Moment of Truth (Realizing I Over-Engineered)
- Architecture 2 Verdict
- Architecture 3: Simple Client-Side Solution
- The Simplification (Choosing Pragmatism Over Perfection)
- What I Decided to Keep (And What to Delete)
- The Final Implementation (From 706 Lines to 31 Lines)
- The Complete Simple Architecture
- Final Code Count
- Architecture Comparison
- Architecture 3 Verdict
- Performance Comparison
- Bundle Size Analysis
- Load Time Impact
- Code Maintainability
- Lessons Learned
- Question Your Assumptions
- Complexity Has Costs
- Simple Solutions Often Win
- Prototype Quickly, Decide Slowly
- Delete Code Confidently
- Implementation Guide
- Step 1: Install Dependencies
- Step 2: Create Mermaid Component
- Step 3: Code-Split for Performance
- Step 4: Add Remark Plugin
- Step 5: Configure Contentlayer/MDX
- Step 6: Add CSS Quality Hints
- Step 7: Use in Content
- Conclusion
- What Worked
- What Didn't
- Final Architecture
- Sources and References
- Project Documentation
- Official Documentation
- Markdown Processing



