José David Baena

Building Interactive Pan/Zoom Controls I Didn't Need

Banner.jpeg
Published on
/18 mins read

📚 Series: Mermaid Diagrams in Next.js

Previously in Part 1: I attempted build-time SVG generation with Puppeteer, which increased build times from 5s to 45s. The theoretical performance benefits weren't worth the practical developer experience costs, so I abandoned the approach.

Architecture 2: Interactive Client-Side (The Over-Engineering Phase)

📦 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)

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.

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

React components work together 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.

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.


👉 Continue to Part 3: Simple CSS Fixed What 308 Lines of JavaScript Couldn't →

In the final post, I'll reveal the embarrassingly simple solution that actually shipped to production, complete with implementation guide and lessons learned.

👈 Previous: Part 1: When Build-Time Rendering Seemed Like a Good Idea