José David Baena

On This Page

On this page

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

Banner.jpeg
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() with await 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:

  1. Attempt 1: Server-side SVG generation (spoiler: hit a wall)
  2. Attempt 2: Interactive pan/zoom controls (over-engineered)
  3. 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:

Loading diagram...

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:

  1. Author-friendly: I should write standard markdown, not HTML or complex syntax
  2. Theme-aware: Diagrams should automatically match light/dark mode
  3. High-quality rendering: SVG should be crisp, not pixelated, at all zoom levels
  4. Performant: Fast page loads, minimal JavaScript bundle size
  5. 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:

Loading diagram...

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:

  1. Finds all code blocks with lang="mermaid"
  2. Runs Mermaid.js to generate SVG
  3. 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:

utils/remark-mermaid-build-time.ts
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:

  1. visit(tree, 'code', ...) - This walks through every code block in the markdown
  2. if (node.lang === 'mermaid') - Checks if it's a Mermaid diagram (not JavaScript, Python, etc.)
  3. mermaid.render(...) - Calls Mermaid.js to generate SVG from the text
  4. 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:

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 build

And immediately got an error:

Error: document is not defined
    at node_modules/mermaid/dist/mermaid.core.mjs:47423:15

What 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-cli
import { 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:

  1. Puppeteer adds 280 MB to node_modules (it bundles Chromium)
  2. Build time explodes - Launching Chrome for each diagram takes seconds
  3. CI/CD nightmare - GitHub Actions, Docker need Chrome installed
  4. 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:

AspectScoreReality
Runtime Performance🟢 GreatZero JavaScript shipped to users
Bundle Size🟢 GreatNo 373 KB Mermaid library
Build Performance🔴 Terrible45 second builds (was 5 seconds)
Developer Experience🔴 TerribleSlow hot-reload, painful development
Maintenance🔴 Terrible280 MB Puppeteer dependency
Complexity🔴 HighHeadless browser, CLI integration
Theme Support🟡 HackyNeed 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:

  1. User visits page with blog post
  2. Browser downloads Mermaid.js (~373 KB, code-split)
  3. Mermaid parses diagram syntax
  4. SVG renders in browser
  5. 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:

Loading diagram...

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:

components/mdx/mermaid.tsx
'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 change
  • mermaid.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:

  1. Mouse wheel zoom (zoom toward cursor position)
  2. Click-and-drag panning (move diagram around)
  3. Momentum scrolling (continues moving after mouse release)
  4. Keyboard shortcuts (+ / - / R / F keys)
  5. Programmatic controls (zoom in/out buttons)

Here's the complete implementation with detailed explanations:

hooks/use-pan-zoom.ts (308 lines)
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:

components/mdx/diagram-controls.tsx (322 lines)
'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:

components/mdx/mermaid-with-controls.tsx
'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:

components/mdx/mermaid-interactive-client.tsx
'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:

  • usePanZoom hook: 308 lines
  • DiagramControls component: 322 lines
  • MermaidWithControls wrapper: 38 lines
  • MermaidInteractive client 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:

Loading diagram...

Why this happens:

  1. Browser renders SVG to bitmap at original size (say, 400×300 pixels)
  2. CSS transform: scale(1.5) scales the bitmap (not the vector)
  3. 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:

Loading diagram...

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:

AspectScoreReality Check
Performance🟡 Medium~30 KB overhead for pan/zoom code
Bundle Size🟡 Medium373 KB Mermaid + 30 KB controls = 403 KB
User Experience🟡 MixedSmooth controls, but hidden by default
Code Complexity🔴 Terrible706 lines for a nice-to-have feature
Maintenance🔴 TerribleCustom physics, event handling, browser bugs
Problem Solved🔴 QuestionableSolving 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:

utils/remark-mermaid-mdx.ts
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:

css/tailwind.css
/* 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 deleted

What 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:

components/mdx/index.tsx
// 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:

  1. Imported components in the MDX file
  2. Global MDX component context
  3. 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:

Loading diagram...

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:

ArchitectureLines of CodeComplexityResult
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

Loading diagram...

Architecture 3 Verdict

AspectScoreNotes
Performance🟢 GreatCode-split, ~373 KB only when needed
Bundle Size🟢 Great706 lines removed (-30 KB)
User Experience🟢 GreatNative browser zoom works fine
Code Complexity🟢 Great28 lines of remark plugin
Maintenance🟢 GreatSimple, clear, maintainable

Decision: ✅ Shipped to production.

Performance Comparison

Bundle Size Analysis

ArchitectureJavaScriptCSSTotalNotes
Build-Time0 KB0 KB0 KB❌ Couldn't implement
Interactive403 KB2 KB405 KB373 KB Mermaid + 30 KB controls
Simple373 KB1 KB374 KBJust 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):

ArchitectureFCPLCPTTINotes
Interactive1.2s2.8s3.1sLoads controls + Mermaid
Simple1.2s2.6s2.9sLoads just Mermaid

Improvement: 200ms faster TTI (6.5% faster)

Code Maintainability

MetricInteractiveSimpleChange
Total Lines70628-96%
Files62-67%
DependenciesReact, hooks, refsJust ReactSimpler
Test ComplexityHigh (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 mermaid

Step 2: Create Mermaid Component

components/mermaid.tsx
'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

components/mermaid-client.tsx
'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

utils/remark-mermaid-mdx.ts
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

contentlayer.config.ts
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

styles/global.css
.mermaid-container svg {
  image-rendering: optimizeQuality;
  shape-rendering: geometricPrecision;
}

Step 7: Use in Content

content/my-post.mdx
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

Loading diagram...

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

Official Documentation

Markdown Processing


Have you built something complex, then deleted most of it? Share your pragmatic engineering stories.

Related Articles