Mermaid.js Deep Dive: How It Actually Works and How to Extend It (English)

Mermaid.js Deep Dive: How It Actually Works and How to Extend It

Sunday, 09 November 2025

//

16 minute read

Introduction

NOTE: This is part of my experiments with AI / a way to spend $1000 Calude Code Web credits. I've fed this a BUNCH of papers, my understanding, questions I had to generate this article. It's fun and fills a gap I haven't seen filled anywhere else.

This post builds on previous articles: If you haven't already, check out Adding mermaid.js with htmx, Switching Themes for Mermaid, and Enhancing Mermaid Diagrams with Pan/Zoom and Export. This deep dive explains the internals behind those implementations.

Mermaid.js is genuinely brilliant. Write simple text, get beautiful diagrams. No more fumbling with Visio or draw.io, losing source files, or maintaining separate image files. Everything lives in markdown, version-controlled alongside your code.

But I wanted to know how it actually works under the hood. How does this:

graph LR
    A[Text] --> B[Magic?]
    B --> C[Beautiful Diagram]

...become an actual SVG? And more importantly, how can you hook into it to add features like the pan/zoom, theme switching, and export functionality I built for this site (now available as @mostlylucid/mermaid-enhancements)?

After a lot of digging through Mermaid's source code and building real extensions, here's everything I learned about how Mermaid works internally and how to extend it properly.

What is Mermaid.js?

Mermaid turns text definitions into diagrams. Think "Markdown for diagrams."

The old way:

  1. Open diagramming tool
  2. Create diagram
  3. Export as PNG
  4. Embed in docs
  5. Need to update? Find the source file, edit, re-export, replace image...

The Mermaid way:

  1. Write diagram in text
  2. Done

Update it? Just edit the text. Version control? It's just text! Works in markdown? Yep!

Diagram Types

Mermaid supports a ridiculous number of diagram types:

graph LR
    A[Flowcharts] --> B[Sequence Diagrams]
    B --> C[Class Diagrams]
    C --> D[State Diagrams]
    D --> E[ER Diagrams]
    E --> F[Gantt Charts]
    F --> G[Pie Charts]
    G --> H[Git Graphs]
    H --> I[User Journeys]
    I --> J[And many more...]

See the Mermaid docs for the full list.

How Mermaid Actually Works: The Pipeline

Here's what happens when Mermaid renders a diagram:

graph TD
    A[Text Definition] --> B[Lexer/Tokenizer]
    B --> C[Parser]
    C --> D[AST Built]
    D --> E[Diagram Type Detected]
    E --> F[Type-Specific Renderer]
    F --> G[SVG Generated]
    G --> H[Inserted into DOM]
    H --> I[Your Enhancements Run]

    style A stroke:#059669,stroke-width:3px,color:#10b981
    style D stroke:#2563eb,stroke-width:3px,color:#3b82f6
    style G stroke:#7c3aed,stroke-width:3px,color:#8b5cf6
    style I stroke:#d97706,stroke-width:3px,color:#f59e0b

Let's break down each step.

Step 1: Text Definition

Everything starts with text. You write diagrams in Mermaid's DSL (domain-specific language):

// Flowchart
const diagram = `
graph TD
    A[Start] --> B{Is it working?}
    B -->|Yes| C[Great!]
    B -->|No| D[Debug time]
`;

Step 2: Lexical Analysis

The lexer breaks text into tokens. For example, this line:

A[Start] --> B{Decision}

Becomes tokens like:

[
    { type: 'NODE_ID', value: 'A' },
    { type: 'NODE_TEXT', value: 'Start' },
    { type: 'ARROW', value: '-->' },
    { type: 'NODE_ID', value: 'B' },
    { type: 'NODE_TEXT', value: 'Decision' },
    { type: 'NODE_SHAPE', value: 'diamond' }  // from { }
]

Step 3: Parsing and AST Generation

The parser consumes tokens and builds an Abstract Syntax Tree (AST):

// Simplified AST structure
{
    type: 'flowchart',
    direction: 'TD',
    nodes: [
        { id: 'A', text: 'Start', shape: 'rect' },
        { id: 'B', text: 'Decision', shape: 'diamond' }
    ],
    edges: [
        { from: 'A', to: 'B', type: 'arrow' }
    ]
}

Mermaid uses different parsers for each diagram type. These are often generated from grammar files using Jison (like Yacc/Bison for JavaScript).

Step 4: Diagram Detection

Mermaid detects diagram type from the first line:

// Simplified detection logic
if (text.match(/^\s*graph/)) return 'flowchart';
if (text.match(/^\s*sequenceDiagram/)) return 'sequence';
if (text.match(/^\s*classDiagram/)) return 'class';
// ... etc

Step 5: Type-Specific Rendering

Each diagram type has its own renderer. The renderer takes the AST and generates SVG elements.

For flowcharts, Mermaid uses the Dagre library for graph layout. For others, it uses custom algorithms or libraries like Cytoscape.

// Simplified flowchart renderer
export const draw = function (text, id, version, diagObj) {
    const graph = diagObj.db;  // The AST
    const svg = d3.select(`#${id}`);

    // Render nodes
    graph.getVertices().forEach(vertex => {
        drawNode(svg, vertex);
    });

    // Render edges
    graph.getEdges().forEach(edge => {
        drawEdge(svg, edge);
    });

    // Apply layout algorithm
    dagre.layout(graph);
};

Step 6: SVG Generation

The renderer produces SVG markup:

<svg xmlns="http://www.w3.org/2000/svg">
    <g class="node">
        <rect x="0" y="0" width="100" height="50"/>
        <text x="50" y="25">Start</text>
    </g>
    <g class="edge">
        <path d="M 100 25 L 200 25" stroke="#333"/>
    </g>
</svg>

Step 7: DOM Insertion

Mermaid finds all .mermaid elements and replaces them with rendered SVG:

// From mermaid.ts
export const init = async function (config, nodes) {
    const nodesToProcess = nodes || document.querySelectorAll('.mermaid');

    for (const node of nodesToProcess) {
        const id = `mermaid-${Date.now()}-${Math.random()}`;
        const txt = node.textContent;

        const { svg } = await render(id, txt);
        node.innerHTML = svg;
    }
};

Step 8: Post-Processing (Where You Come In)

After Mermaid inserts the SVG, you can enhance it. This is where all my enhancements hook in:

  • Pan/zoom functionality
  • Control buttons
  • Export capabilities
  • Theme switching

More on this below.

Extending Mermaid: The Extension Points

Now that we know how Mermaid works, let's explore how to extend it.

1. Configuration

The most basic extension is configuration:

import mermaid from 'mermaid';

mermaid.initialize({
    startOnLoad: true,
    theme: 'dark',
    securityLevel: 'loose',
    flowchart: {
        curve: 'basis',
        padding: 15
    }
});

2. Theme Customization

I covered this extensively in Switching Themes for Mermaid, but here's the key implementation:

The Problem with Theme Switching

Mermaid needs to be initialized with a theme, and you can't change it after. BUT if you want to re-render diagrams with a new theme, you need the original diagram source—which Mermaid doesn't store in the DOM.

The Solution

Store the original content before rendering, then restore and re-render when switching themes:

// From my theme-switcher implementation
const originalData = new Map();

// Save original content before first render
const saveOriginalData = async () => {
    const elements = document.querySelectorAll('.mermaid');
    elements.forEach(element => {
        const id = element.id || `mermaid-${Date.now()}`;
        element.id = id;

        // Store the original diagram source
        if (!originalData.has(id)) {
            originalData.set(id, element.textContent?.trim());
        }
    });
};

// When theme changes, restore and re-render
const loadMermaid = async (theme) => {
    mermaid.initialize({
        startOnLoad: false,
        theme: theme
    });

    const elements = document.querySelectorAll('.mermaid');
    for (const element of elements) {
        const source = originalData.get(element.id);
        if (source) {
            element.innerHTML = '';  // Clear
            element.removeAttribute('data-processed');

            const { svg } = await mermaid.render(
                `mermaid-svg-${element.id}`,
                source
            );
            element.innerHTML = svg;
        }
    }
};

Multiple theme detection methods (sites handle themes differently):

function detectTheme() {
    // Check various sources
    if (typeof window.__themeState !== 'undefined') {
        return window.__themeState;
    }
    if (localStorage.theme) {
        return localStorage.theme;
    }
    if (document.documentElement.classList.contains('dark')) {
        return 'dark';
    }
    if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
        return 'dark';
    }
    return 'light';
}

See the full theme switcher code for details.

3. Post-Render Enhancements

This is where the real magic happens. After Mermaid renders, you can add interactive features.

I covered this extensively in Enhancing Mermaid Diagrams with Pan/Zoom and Export, so I'll highlight key techniques here.

Wrapping Diagrams

Create a wrapper container for controls:

function wrapDiagram(element) {
    if (element.closest('.mermaid-wrapper')) {
        return element.closest('.mermaid-wrapper');
    }

    const wrapper = document.createElement('div');
    wrapper.className = 'mermaid-wrapper';
    wrapper.id = `wrapper-${element.id}`;

    element.parentNode.insertBefore(wrapper, element);
    wrapper.appendChild(element);

    return wrapper;
}

Adding Pan/Zoom

Using svg-pan-zoom:

import svgPanZoom from 'svg-pan-zoom';

const panZoomInstances = new Map();

function initPanZoom(svgElement, diagramId) {
    // Clean up existing instance
    if (panZoomInstances.has(diagramId)) {
        panZoomInstances.get(diagramId).destroy();
        panZoomInstances.delete(diagramId);
    }

    const instance = svgPanZoom(svgElement, {
        zoomEnabled: true,
        controlIconsEnabled: false,
        fit: true,
        center: true,
        minZoom: 0.1,
        maxZoom: 10
    });

    panZoomInstances.set(diagramId, instance);
    return instance;
}

Control Buttons

Create floating control panel:

function createControlButtons(container, diagramId) {
    const controlsDiv = document.createElement('div');
    controlsDiv.className = 'mermaid-controls';

    const buttons = [
        { icon: 'bx-fullscreen', title: 'Fullscreen', action: 'fullscreen' },
        { icon: 'bx-zoom-in', title: 'Zoom In', action: 'zoomIn' },
        { icon: 'bx-zoom-out', title: 'Zoom Out', action: 'zoomOut' },
        { icon: 'bx-reset', title: 'Reset', action: 'reset' },
        { icon: 'bx-move', title: 'Pan', action: 'pan' },
        { icon: 'bx-image', title: 'Export PNG', action: 'exportPng' },
        { icon: 'bx-code-alt', title: 'Export SVG', action: 'exportSvg' }
    ];

    buttons.forEach(btn => {
        const button = document.createElement('button');
        button.className = `mermaid-control-btn bx ${btn.icon}`;
        button.setAttribute('data-action', btn.action);
        button.setAttribute('data-diagram-id', diagramId);
        controlsDiv.appendChild(button);
    });

    container.appendChild(controlsDiv);
}

Event Delegation (Performance!)

Don't attach listeners to every button. Use event delegation:

document.addEventListener('click', (e) => {
    const target = e.target;
    if (!target.classList.contains('mermaid-control-btn')) return;

    const action = target.getAttribute('data-action');
    const diagramId = target.getAttribute('data-diagram-id');
    const panZoom = panZoomInstances.get(diagramId);

    switch (action) {
        case 'zoomIn': panZoom?.zoomIn(); break;
        case 'zoomOut': panZoom?.zoomOut(); break;
        case 'reset': panZoom?.reset(); break;
        // ... etc
    }
});

Export Functionality

The Challenge: SVG elements have dynamic sizing, pan/zoom transforms, and inherited styles. To export properly, you need to:

  1. Clone the SVG
  2. Preserve dimensions
  3. Remove transforms
  4. Convert to PNG or SVG

Using html-to-image:

import { toPng, toSvg } from 'html-to-image';

async function exportDiagram(container, format, diagramId) {
    const svgElement = container.querySelector('svg');
    if (!svgElement) return;

    // Clone to avoid modifying original
    const clonedSvg = svgElement.cloneNode(true);

    // Get or calculate viewBox
    let viewBox = clonedSvg.getAttribute('viewBox');
    if (!viewBox) {
        const bbox = svgElement.getBBox();
        viewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`;
        clonedSvg.setAttribute('viewBox', viewBox);
    }

    // Set explicit dimensions
    const [, , width, height] = viewBox.split(' ').map(Number);
    clonedSvg.setAttribute('width', width);
    clonedSvg.setAttribute('height', height);

    // Remove pan-zoom transforms
    clonedSvg.removeAttribute('style');

    // Create off-screen container
    const temp = document.createElement('div');
    temp.style.position = 'absolute';
    temp.style.left = '-9999px';
    temp.appendChild(clonedSvg);
    document.body.appendChild(temp);

    // Export
    const dataUrl = format === 'png'
        ? await toPng(clonedSvg, { pixelRatio: 2 })
        : await toSvg(clonedSvg);

    downloadFile(dataUrl, `diagram-${Date.now()}.${format}`);

    // Cleanup
    document.body.removeChild(temp);
}

Critical details:

  • Preserve viewBox - Captures the entire diagram, not just visible portion
  • Off-screen rendering - Avoid affecting the displayed diagram
  • pixelRatio: 2 - High-DPI for crisp PNG exports

See the full export code for more details.

The Complete Enhancement Package

I packaged all these enhancements as @mostlylucid/mermaid-enhancements. See Publishing Mermaid Enhancements as an npm Package for full details.

Here's how it all fits together:

graph TB
    A[User Initializes] --> B[init Function]
    B --> C[initMermaid]
    B --> D[enhanceMermaidDiagrams]

    C --> E[Theme Detection]
    C --> F[Event Listeners]
    C --> G[Mermaid Rendering]

    E --> E1[Global State]
    E --> E2[LocalStorage]
    E --> E3[DOM Class]
    E --> E4[OS Preference]

    F --> F1[Custom Events]
    F --> F2[Media Query]

    G --> H[Apply Enhancements]
    D --> H

    H --> I[Wrap Diagrams]
    H --> J[Init Pan/Zoom]
    H --> K[Add Controls]

    I --> L[Interactive Diagram]
    J --> L
    K --> L

    L --> M[User Interactions]
    M --> M1[Zoom In/Out]
    M --> M2[Pan]
    M --> M3[Fullscreen]
    M --> M4[Export PNG/SVG]

    style A stroke:#059669,stroke-width:3px,color:#10b981
    style L stroke:#2563eb,stroke-width:3px,color:#3b82f6
    style M stroke:#7c3aed,stroke-width:3px,color:#8b5cf6

Usage

npm install @mostlylucid/mermaid-enhancements
import { init } from '@mostlylucid/mermaid-enhancements';
import '@mostlylucid/mermaid-enhancements/styles.css';

await init();

That's it! Your Mermaid diagrams now have:

  • ✅ Interactive pan/zoom
  • ✅ Fullscreen lightbox
  • ✅ PNG/SVG export
  • ✅ Automatic theme switching
  • ✅ Responsive design

Integration with HTMX

As I covered in Adding mermaid.js with htmx, you need to re-initialize Mermaid after HTMX content swaps:

// On page load
document.addEventListener('DOMContentLoaded', function () {
    mermaid.initialize({ startOnLoad: true });
});

// After HTMX swaps content
document.body.addEventListener('htmx:afterSwap', function(evt) {
    mermaid.run();
});

With the enhancements package:

import { init, enhanceMermaidDiagrams } from '@mostlylucid/mermaid-enhancements';

// Initial load
await init();

// After HTMX swap
document.body.addEventListener('htmx:afterSwap', async function() {
    await init();  // Re-init Mermaid with current theme
    enhanceMermaidDiagrams();  // Re-apply enhancements
});

Best Practices I Learned

After building this stuff and debugging weird edge cases, here's what works:

1. Always Store Original Content

Mermaid doesn't preserve the original diagram source after rendering. You MUST store it yourself:

const originalData = new Map();

// Before first render
element.setAttribute('data-original-code', element.textContent);
originalData.set(element.id, element.textContent);

// When re-rendering
element.innerHTML = originalData.get(element.id);

2. Clean Up Instances

Memory leaks are real. Destroy instances before creating new ones:

if (panZoomInstances.has(id)) {
    try {
        panZoomInstances.get(id).destroy();
    } catch (e) {
        console.warn('Failed to destroy:', e);
    }
    panZoomInstances.delete(id);
}

3. Use Event Delegation

Don't attach listeners to individual buttons:

// ❌ Don't do this
buttons.forEach(btn => {
    btn.addEventListener('click', handler);
});

// ✅ Do this
document.addEventListener('click', (e) => {
    if (e.target.matches('.mermaid-control-btn')) {
        handleClick(e.target);
    }
});

4. Handle Cloudflare Rocket Loader

Rocket Loader delays JavaScript execution. Wait for dependencies:

function waitForDependencies(maxAttempts = 50) {
    return new Promise((resolve) => {
        let attempts = 0;

        const check = () => {
            if (window.mermaid && window.htmx && window.Alpine) {
                resolve();
            } else if (attempts >= maxAttempts) {
                resolve();  // Give up
            } else {
                attempts++;
                setTimeout(check, Math.min(50 * Math.pow(1.2, attempts), 500));
            }
        };

        check();
    });
}

And exclude your main script from Rocket Loader:

<script src="main.js" data-cfasync="false"></script>

5. Timing is Everything

Use requestAnimationFrame for better timing than arbitrary setTimeout:

// After Mermaid renders
await mermaid.run();

// Wait for paint before enhancing
await new Promise(resolve => {
    requestAnimationFrame(() => {
        requestAnimationFrame(() => {
            enhanceMermaidDiagrams();
            resolve();
        });
    });
});

6. Defensive SVG Handling

SVGs can be weird. Always check:

const svgElement = container.querySelector('svg');
if (!svgElement) {
    console.warn('No SVG found');
    return;
}

// Clone before modifying
const cloned = svgElement.cloneNode(true);

// Ensure viewBox exists
let viewBox = cloned.getAttribute('viewBox');
if (!viewBox) {
    const bbox = svgElement.getBBox();
    viewBox = `${bbox.x} ${bbox.y} ${bbox.width} ${bbox.height}`;
}

Debugging Tips

Console Output

With proper initialization, you should see:

Saving original data
Loading mermaid with theme: dark
Mermaid initialized
Enhanced 3 diagrams

Testing Checklist

After implementing enhancements:

  • Diagrams render on page load
  • Pan/zoom controls work
  • Fullscreen opens/closes (X, click outside, ESC)
  • PNG export captures full diagram
  • SVG export preserves vectors
  • Works after HTMX content swap
  • Theme switching re-renders correctly
  • Mobile responsive
  • Dark mode styling
  • Keyboard accessibility

Common Issues

Diagram not rendering:

  • Check browser console for errors
  • Verify Mermaid loaded (window.mermaid)
  • Check diagram syntax

Pan/zoom not working:

  • Verify svg-pan-zoom initialized
  • Check for conflicting CSS (pointer-events: none)
  • Inspect instance map

Export captures only corner:

  • Missing viewBox preservation
  • Transform not removed
  • Check cloning logic

Theme not switching:

  • Original data not saved
  • data-processed not reset
  • Event listeners not registered

Performance Considerations

Lazy Initialization

Don't load enhancements until needed:

let enhancementsLoaded = false;

async function loadEnhancements() {
    if (enhancementsLoaded) return;

    const { enhanceMermaidDiagrams } = await import('./enhancements.js');
    enhanceMermaidDiagrams();
    enhancementsLoaded = true;
}

// Load on first interaction
document.addEventListener('click', (e) => {
    if (e.target.closest('.mermaid')) {
        loadEnhancements();
    }
}, { once: true });

Intersection Observer

Render diagrams only when visible:

const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
        if (entry.isIntersecting) {
            renderDiagram(entry.target);
            observer.unobserve(entry.target);
        }
    });
}, { rootMargin: '100px' });

document.querySelectorAll('.mermaid').forEach(el => {
    observer.observe(el);
});

Debounce Re-renders

On resize or theme change:

let timeout;
window.addEventListener('resize', () => {
    clearTimeout(timeout);
    timeout = setTimeout(() => {
        panZoomInstances.forEach(instance => {
            instance.resize();
            instance.fit();
        });
    }, 250);
});

In Conclusion

Mermaid.js is fantastic out of the box, but understanding how it works internally lets you build some really cool enhancements. The key insights:

  1. Rendering Pipeline: Text → Lexer → Parser → AST → Renderer → SVG
  2. Extension Points: Config, themes, post-render enhancements
  3. Store Original Data: Mermaid doesn't do it for you
  4. Clean Up Resources: Memory leaks are real
  5. Event Delegation: Better performance
  6. Multiple Theme Sources: Sites handle themes differently
  7. Timing Matters: Use requestAnimationFrame

All the techniques I've covered here are used in production on this site and packaged in @mostlylucid/mermaid-enhancements. The complete source is available at mostlylucidweb/mostlylucid-mermaid.

Resources

Try the controls on the diagrams above! 📊

logo

© 2025 Scott Galloway — Unlicense — All content and source code on this site is free to use, copy, modify, and sell.