Simple CSS Fixed What 308 Lines of JavaScript Couldn't

- Published on
- /22 mins read
📚 Series: Mermaid Diagrams in Next.js
- Part 1: When Build-Time Rendering Seemed Like a Good Idea
- Part 2: Building Interactive Controls I Didn't Need
- Part 3: Simple CSS Fixed Everything ← You are here
Previously in this series: Part 1 covered failed build-time SVG generation with Puppeteer (45s builds). Part 2 detailed building 706 lines of pan/zoom controls with momentum physics, only to discover SVG pixelation issues and realize users didn't need interactive features.
Architecture 3: Simple Client-Side Solution
📦 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)
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 the most important question:
"Do readers actually need to pan and zoom diagrams?"
Looking 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 to Keep and 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.
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"
Note: The text-rendering property doesn't affect SVG text rendering in most browsers. Only these two properties meaningfully improve SVG diagram quality.
The result: Crisp, sharp SVG rendering at all zoom levels. Even when users zoom to 300% with browser zoom, vectors stay sharp.
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.
Handling Mermaid Syntax Errors
Mermaid diagrams can fail silently if syntax is wrong. Here's how to debug and handle errors gracefully:
Common Errors
Invalid Arrow Syntax:
graph TD
A[Start] -> B[End] <!-- Wrong! Should be --> -->Error Message:
Error rendering diagram: Syntax error in graph
How to Debug
Validate in Mermaid Live Editor: https://mermaid.live/
- Paste your diagram code
- See errors highlighted in real-time
- Test different syntax variations
Check Browser Console:
- Mermaid errors include line numbers
- Look for stack traces with specific syntax issues
Test with Minimal Diagram:
- Start with simplest possible diagram
- Add complexity incrementally
- Identify which addition breaks rendering
Production Best Practice
The basic error handling in our component can be enhanced:
mermaid.render(id, chart)
.then(({ svg }) => {
if (ref.current) {
ref.current.innerHTML = svg
}
})
.catch((error) => {
// Log full error for developers
console.error('Mermaid rendering error:', {
chart,
error: error.message,
stack: error.stack
})
// Show friendly message to users
if (ref.current) {
ref.current.innerHTML = `
<div style="
border: 2px solid #ef4444;
border-radius: 0.5rem;
padding: 1rem;
background: #fef2f2;
color: #991b1b;
">
<strong>⚠️ Unable to render diagram</strong>
<p style="margin: 0.5rem 0 0 0; font-size: 0.875rem;">
This diagram contains a syntax error.
${process.env.NODE_ENV === 'development'
? `Check the browser console for details.`
: `Please report this issue.`
}
</p>
</div>
`
}
})Key improvements:
- Development mode: Shows detailed error for debugging
- Production mode: Shows user-friendly message
- Console logging: Preserves full error context
- Visual feedback: Clear error styling
Making Diagrams Accessible
Screen readers can't interpret visual diagrams. Here's how to make them accessible:
Basic Accessibility (Current Implementation)
Our component includes minimal accessibility:
<div
ref={ref}
className="mermaid-container my-8"
role="img" // Announces "image" to screen readers
aria-label="Mermaid diagram" // Generic description
/>Limitation: "Mermaid diagram" doesn't describe the content.
Better: Descriptive Labels
Pass meaningful descriptions:
interface MermaidProps {
chart: string
ariaLabel?: string // Optional descriptive label
}
export function Mermaid({ chart, ariaLabel }: MermaidProps) {
// ... rendering logic
return (
<div
ref={ref}
className="mermaid-container my-8"
role="img"
aria-label={ariaLabel || 'Mermaid diagram'}
/>
)
}Usage in content:
<Mermaid
chart="graph TD\n A[Login]-->B[Validate]"
ariaLabel="User authentication flow showing login, validation, and redirect steps"
/>Best: Provide Text Alternative
For complex diagrams, include a text description:
<div className="diagram-wrapper">
<Mermaid
chart="graph TD\n A[Login]-->B[Validate]-->C[Dashboard]"
ariaLabel="Three-step authentication process"
/>
<details className="mt-4 text-sm text-gray-600 dark:text-gray-400">
<summary className="cursor-pointer font-medium">
📝 Text description of diagram
</summary>
<div className="mt-2 space-y-2">
<p><strong>Process flow:</strong></p>
<ol className="list-decimal list-inside space-y-1 ml-4">
<li>User enters credentials at Login screen</li>
<li>System validates credentials against database</li>
<li>On success, user is redirected to Dashboard</li>
</ol>
</div>
</details>
</div>WCAG 2.1 Compliance
Success Criterion 1.1.1 (Non-text Content):
All non-text content that is presented to the user has a text alternative that serves the equivalent purpose.
Our approach:
- Level A (Minimum):
aria-labelwith description ✅ - Level AA (Recommended): Text alternative in
<details>✅ - Level AAA (Enhanced): Long description linked via
aria-describedby
Implementation tip: For frequently used diagrams (like architecture diagrams in multiple posts), create reusable components with baked-in descriptions.
Performance Testing Methodology
The performance comparisons in this series were measured systematically. Here's the methodology:
Test Setup
Tools Used:
- Chrome DevTools Performance tab
- Lighthouse CI
- Network throttling (built into DevTools)
Test Environment:
Browser: Chrome 120+ (latest stable)
Device: Desktop (no CPU throttling)
Network: Throttled to "Fast 3G"
- Download: 1.6 Mbps
- Upload: 750 Kbps
- RTT: 40ms
Cache: Disabled (simulates first-time visitor)
Extensions: Disabled (Incognito mode)Why "Fast 3G"?
- Represents global median mobile connection
- Shows performance differences that 4G/WiFi might hide
- Conservative estimate for user experience
Measurement Process
- Clear browser cache and storage
- Open DevTools → Performance tab
- Start recording
- Navigate to test page (cold load)
- Stop recording after page fully interactive
- Repeat 5 times for each architecture
- Calculate median of middle 3 runs (discard highest/lowest)
Metrics Captured
| Metric | What It Measures | Why It Matters |
|---|---|---|
| FCP (First Contentful Paint) | When first content appears | User sees page isn't blank |
| LCP (Largest Contentful Paint) | When main content visible | User sees meaningful content |
| TTI (Time to Interactive) | When page fully responds | User can actually interact |
Our Results (Median of 3 runs):
| Architecture | FCP | LCP | TTI | Bundle Size |
|---|---|---|---|---|
| Interactive | 1.2s ±0.1s | 2.8s ±0.2s | 3.1s ±0.3s | 403 KB (v10.x) |
| Simple | 1.2s ±0.1s | 2.6s ±0.1s | 2.9s ±0.2s | 373 KB (v10.x) |
Key Finding: 200ms faster TTI (6.5% improvement)
Real-World Context
On Fast 4G/WiFi (10+ Mbps):
- Difference shrinks to ~50ms
- Both architectures feel instant
- Bundle size matters less
On Slow 3G (400 Kbps):
- Difference grows to ~800ms
- Every KB counts
- Simple architecture provides noticeably better UX
Takeaway: Optimize for the slowest reasonable connection you expect users to have.
Reproducing These Tests
# Install Lighthouse CI
npm install -g @lhci/cli
# Run automated tests
lhci autorun --config=./lighthouserc.json
# Or use DevTools manually:
# 1. Open DevTools (F12)
# 2. Performance tab → Settings (gear icon)
# 3. Network: "Fast 3G", CPU: "No throttling"
# 4. Click Record → Navigate → StopEpilogue: When Simple Gets Even Simpler
The Real-World Performance Problem
After shipping the simple solution to production, I discovered a new issue: blog posts with multiple diagrams (5+) still had performance problems.
The Problem:
Post with 8 diagrams:
- Page load: 1.2s (fine)
- Mermaid.js loads: 373 KB
- All 8 diagrams render immediately
- TTI: 4.2s (not fine!)
Even though individual diagrams were simple, rendering all of them upfront blocked the main thread for ~3 seconds.
The Solution: Lazy Loading with Intersection Observer
Unlike Architecture 2's pan/zoom controls (solving imaginary problems), this addressed a real user experience issue: readers had to wait for diagrams they weren't viewing yet.
The fix: Only render diagrams when they're about to enter the viewport.
import { useEffect, useRef, useState } from 'react'
export function useIntersectionObserver(
options: IntersectionObserverInit = {}
) {
const [isIntersecting, setIsIntersecting] = useState(false)
const targetRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const target = targetRef.current
if (!target) return
const observer = new IntersectionObserver(
([entry]) => {
// Only trigger once when entering viewport
if (entry.isIntersecting) {
setIsIntersecting(true)
observer.disconnect()
}
},
{
rootMargin: '300px', // Start loading 300px before visible
...options,
}
)
observer.observe(target)
return () => observer.disconnect()
}, [options])
return { targetRef, isIntersecting }
}'use client'
import { Mermaid } from './mermaid'
import { useIntersectionObserver } from '@/hooks/use-intersection-observer'
export function MermaidLazy({ chart }: { chart: string }) {
const { targetRef, isIntersecting } = useIntersectionObserver()
return (
<div ref={targetRef} className="min-h-[200px]">
{isIntersecting ? (
<Mermaid chart={chart} />
) : (
<div className="animate-pulse bg-gray-200 dark:bg-gray-700 h-48 rounded" />
)}
</div>
)
}How it works:
- Placeholder shown for diagrams below the fold
- Intersection Observer watches when diagram approaches viewport
- 300px root margin ensures diagram loads before user scrolls to it
- One-time trigger - once loaded, stays loaded (no re-rendering)
Performance Impact
Before (eager loading):
| Metric | Value |
|---|---|
| TTI (8 diagrams) | 4.2s |
| Initial bundle exec | 3.1s |
| Diagrams rendered | 8 (all) |
After (lazy loading):
| Metric | Value |
|---|---|
| TTI | 2.9s ⬇️ 31% faster |
| Initial bundle exec | 1.8s |
| Diagrams rendered | 2 (above fold) |
Key improvement: Only diagrams above the fold render initially. Others render as user scrolls.
Why This Doesn't Contradict "Simple"
This is different from Architecture 2's pan/zoom controls because:
- Solves real problem: Multiple diagrams genuinely hurt performance
- Small implementation: ~50 lines vs 706 lines
- Uses platform APIs: Intersection Observer is built-in, not custom physics
- Zero maintenance: No event handling, no edge cases, no cross-browser bugs
- Progressive enhancement: Page works without it (just slower)
The Architecture 2 pan/zoom problem:
- ❌ Solved imaginary problems (users didn't need pan/zoom)
- ❌ 706 lines of custom code
- ❌ Complex event handling and physics
- ❌ Created new bugs (SVG pixelation)
This lazy loading solution:
- ✅ Solves real problems (measured performance impact)
- ✅ 50 lines using native APIs
- ✅ No complex state or events
- ✅ No new bugs introduced
The lesson: Complexity is justified when it solves measured problems with proportional solutions. Architecture 2 added massive complexity for theoretical benefits. This adds minimal complexity for measurable gains.
When to Use Lazy Loading
Use lazy loading if:
- Posts regularly contain 4+ diagrams
- Performance testing shows TTI > 3.5s
- Users report slow page loads
Skip lazy loading if:
- Most posts have 1-2 diagrams
- TTI already < 3s
- Diagrams are small/simple
Our case: Blog posts averaged 3 diagrams (no lazy loading needed), but technical deep-dives had 8+ (lazy loading essential). We conditionally enable it:
// Simple posts (1-3 diagrams): Use regular Mermaid
<Mermaid chart="graph TD..." />
// Complex posts (4+ diagrams): Use lazy variant
<MermaidLazy chart="graph TD..." />Total added complexity: 50 lines. Performance improvement: 31% faster TTI on diagram-heavy posts.
This is pragmatic engineering: Add complexity only when benefits are measurable and proportional.
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.
Related Resources
Documentation created during this journey:
- MERMAID_SIMPLIFICATION_SUMMARY.md - Complete change log
- MERMAID_IMPLEMENTATION_GUIDE.md - Basic usage
- MERMAID_PAN_ZOOM_SPECIFICATION.md - Interactive controls design (historical)
External references:
- Mermaid.js Documentation - Official Mermaid docs
- Remark Plugins - Markdown transformation
- Next.js Dynamic Imports - Code-splitting guide
🎉 Series Complete!
👈 Previous: Part 2: Building Interactive Controls I Didn't Need
Missed the beginning? Start from Part 1: When Build-Time Rendering Seemed Like a Good Idea
Want the complete journey? All three posts cover:
- Build-time SVG generation attempts (Part 1)
- Interactive pan/zoom implementation (Part 2)
- The simple solution that shipped (Part 3)
Have you built something complex, then deleted most of it? Share your pragmatic engineering stories.
On this page
- Previously in this series: Part 1 covered failed build-time SVG generation with Puppeteer (45s builds). Part 2 detailed building 706 lines of pan/zoom controls with momentum physics, only to discover SVG pixelation issues and realize users didn't need interactive features.
- Architecture 3: Simple Client-Side Solution
- The Simplification (Choosing Pragmatism Over Perfection)
- What to Keep and 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
- Handling Mermaid Syntax Errors
- Common Errors
- How to Debug
- Production Best Practice
- Making Diagrams Accessible
- Basic Accessibility (Current Implementation)
- Better: Descriptive Labels
- Best: Provide Text Alternative
- WCAG 2.1 Compliance
- Performance Testing Methodology
- Test Setup
- Measurement Process
- Metrics Captured
- Real-World Context
- Reproducing These Tests
- Epilogue: When Simple Gets Even Simpler
- The Real-World Performance Problem
- The Solution: Lazy Loading with Intersection Observer
- Performance Impact
- Why This Doesn't Contradict "Simple"
- When to Use Lazy Loading
- Conclusion
- What Worked
- What Didn't
- Final Architecture
- Related Resources
